component

Person

A sculpted, clothed, skinned figure generated from a seed: one continuous body with a sculpted face, lidded eyes that follow you, and hands with fingers, in three style templates (stylized by default, realistic, anime). Every part is configurable as plain JSON: the body (build, tone, height), the face (eyes, brows, nose, lips, blush), hair, and clothing as ordered layers (shirts, sweaters, vests, coats, dresses, robes, trousers, jeans, skirts, shoes, boots, belts, scarves, capes) that fit any body. Role presets, swappable cosmetic skins, hats and accessories, poses, idle breathing and blinking. Give it a patrol or a wander radius and it walks: a route that is a pure function of the clock, following the ground, with a walk cycle and a step aside for the player and other walkers. A route with a departure time is a one-way trip (to a desk, to bed) that reports its arrival, and actions put a key prompt over it (Talk, Info) when the player comes near. Meshes build in the background, coarse first, with full detail near the camera. Place one at a spawn point, or use it as the Player's third-person body.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add person

Pulls interactable, sign, @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Person } from './runek/Person'

<Person position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Person", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

Prop Type Default Description
style "stylized" | "realistic" | "anime" "stylized" Proportion and face template: stylized (default) is an animated-film look with a big head and eyes, small features, and slender limbs; realistic keeps canonical human proportions; anime pushes the eyes and legs further.
kind "villager" | "merchant" | "guard" | "sailor" | "farmer" | "noble" | "scholar" | "traveler" "villager" Role preset: fills outfit, hat, and accessories unless you set them.
gender "feminine" | "masculine" | "neutral" Silhouette cues only; every trait it influences stays overridable. Seeded when unset.
age "child" | "adult" | "elder" "adult" Head-to-body ratio, stature, and (for elder) a stooped spine.
body PersonBodySpec The body: build, skin tone, and height. The flat props below are shorthands for the same fields; when both are set, this object wins.
face PersonFaceSpec The face, part by part: eye color and size, brow color and weight, nose shape, lip color and fullness, blush.
clothes PersonGarment[] Clothing layers, inner to outer, each with its own type, color, and options. The order decides the fit: a shirt listed before the jeans is tucked in. Replaces outfit; [] is the bare base body (a smooth mannequin).
height number Standing height, in units. Every other measurement derives from it.
build "slim" | "average" | "stocky" Limb thickness and torso depth. Seeded when unset.
skinTone color Skin color; seeded from a curated set of tones when unset.
skin "sailor" | "hoodie" | "festival" | "chef" | "harvest" | "winter" | "voyage" | "ceremony" | "atelier" | "pirate" | "bartender" | "office" | "casual" | "preppy" | PersonSkin Cosmetic loadout: a preset name from PERSON_SKINS (e.g. "winter") or a custom PersonSkin bundle. Individual props win over it. Plain JSON either way, so an editor or an in-world UI can swap a figure’s whole look by writing this one prop.
hair "short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald" | PersonHairSpec A style name, or { style, color }.
hairColor color
facialHair "none" | "stubble" | "moustache" | "beard"
outfit "tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "uniform" One-word outfit; expands to a clothes stack (e.g. tunic: trousers, tunic, shoes).
topColor color Outermost top’s color; defaults to a seeded shade of a palette slot. A garment’s own color wins over it.
bottomColor color Bottoms’ color; defaults to a seeded shade of the palette’s woodDark, stone, or metal.
shoeColor color Footwear color; defaults to the palette’s metal.
hat "none" | "cap" | "straw" | "brim" | "bandana" | "hood" | "chef"
accessories "belt" | "scarf" | "cape" | "bag" | "glasses" | "staff"[]
pose "stand" | "sit" | "lean" | "work" | "wave" | "type" | "play" | "drink" | "lie" "stand" What the figure is doing when it isn’t walking. sit and type need a seat under them (type is seated at a desk, fingers tapping); work leans over a counter; play works controls at waist height; drink holds a mug and sips from it now and then; lie lies on its back centered on position, head toward local -Z, so it takes a Bed’s position and rotation at mattress height.
patrol [x, y, z][] Walk these waypoints, relative to position and turning with rotation, in units. Height follows the ground; a waypoint’s y lifts the figure above it.
wander number Wander a seeded loop of five points within this radius of position, in units. patrol wins when both are set.
speed number Walking speed, in units per second. Defaults by age: child 1.0, adult 1.3, elder 0.8.
pause number 1.5 Seconds held at each waypoint. Walking always stands; the authored pose plays here.
loop "loop" | "pingpong" "loop" loop walks from the last waypoint back to the first; pingpong retraces the route.
route [x, y, z][] A one-way trip through these waypoints, relative to position like patrol, setting off at departAt. Before then the figure waits at the first point facing its rotation; after the last it stays there, facing the way it came, in its pose. Wins over patrol and wander. Give a new trip a new position (the old one’s end) and departAt.
departAt number 0 When the route trip sets off, in epoch milliseconds (Date.now()). Plain data, so every viewer sees the figure at the same point of its trip. Unset, the trip is long over.
onArrive function (code only) Called once per trip (route + departAt) when the figure has arrived, on the first frame it has, so a trip that ended while the world was paused or unmounted still reports.
gait number Walk-cycle speed, in units per second, for a figure something else moves (a cart, a script). Unset, a route drives it, and a Player’s body walks at the avatar’s speed.
idle boolean true Breathing, weight shift, and blinking.
lookAt boolean true Turn the head (and eyes) toward the player’s avatar (or the camera, when no Player is mounted) when it comes within lookRadius.
lookRadius number 5 How close the avatar must be for lookAt to engage, in units.
label string Floating name above the head.
bubble string A short line in a speech bubble over the head (what the figure is saying or doing right now). Long lines wrap and are cut after a few.
emote "sleep" | "alert" | "think" | "happy" | "coffee" A small animated sign over the head: sleep (rising z’s), alert (a bouncing !), think (pulsing dots), happy (a heart), coffee (a steaming cup).
actions InteractionAction[] What the player can do with this figure (Talk, Info). When the avatar comes within actionRadius a prompt shows them over the head, with the key each world controls action is bound to; pressing one calls onAction with its id. Only the nearest figure (or other Interactable) in range shows a prompt and takes the key.
actionRadius number 2 How close the avatar must come for actions, in units.
onAction function (code only)
collider boolean true Capsule collider, so the figure is something you bump into.
physics boolean true Render as a bare visual with no RigidBody: for a parent that owns the physics (e.g. as Player’s third-person avatar). When false, collider is ignored.
detail "low" | "high" | "auto" "auto" auto builds the full-detail mesh only within ~10 units of the camera; high always; low never.
position [x, y, z] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
seed number 1 Seed for the deterministic variation: same seed, same result.

PersonBodySpec

Prop Type Default Description
build "slim" | "average" | "stocky" Limb thickness and torso depth.
tone color Skin color.
height number Standing height, in units.

PersonFaceSpec

Prop Type Default Description
eyes eyes
brows brows Brow color defaults to the hair color; weight thickens them.
nose "button" | "straight" | "broad"
lips lips
blush number Cheek flush, 0 (none) to 1.

Person.eyes

Prop Type Default Description
color color
size number

Person.brows

Prop Type Default Description
color color
weight number

Person.lips

Prop Type Default Description
color color
fullness number

PersonGarment

Prop Type Default Description
type "tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "trousers" | "jeans" | "shorts" | "skirt" | "shoes" | "boots" | "belt" | "scarf" | "cape" required
color color
sleeves "short" | "none" | "long" Tops only.
neck "crew" | "v" | "collar" Tops only.
length number 0 to 1: how far it reaches (a top’s hem, a leg’s length, a skirt or boot’s drop).

PersonSkin

Prop Type Default Description
outfit "tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "uniform"
clothes PersonGarment[] Full layer stack; wins over outfit.
topColor color
bottomColor color
shoeColor color
hat "none" | "cap" | "straw" | "brim" | "bandana" | "hood" | "chef"
hair "short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald"
hairColor color
accessories "belt" | "scarf" | "cape" | "bag" | "glasses" | "staff"[]

PersonHairSpec

Prop Type Default Description
style "short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald"
color color

InteractionAction

Prop Type Default Description
id string required Passed back to onAction.
label string required Shown in the prompt, e.g. Talk.
control string required The world controls action that triggers it (declare talk: ['KeyT'] in the world’s controls). The prompt shows the key bound to it, so a remap relabels it.

Migrate

v0.13.0 → v0.14.0. The figure is rebuilt as one sculpted, skinned body with clothes as layers, and the default style is now stylized. No prop was removed: every flat prop still works, and the new part objects (body, face, hair, clothes) are optional.

v0.13.0
<Person
  seed={4}
  outfit="shirt"
  topColor="#e8e2d2"
  skinTone="#c98d63"
  hairColor="#2b1d14"
/>
v0.14.0
<Person
  seed={4}
  body={{ tone: '#c98d63' }}
  hair={{ style: 'short', color: '#2b1d14' }}
  clothes={[
    { type: 'shirt', sleeves: 'short', color: '#e8e2d2' },
    { type: 'trousers' },
    { type: 'shoes' },
  ]}
/>

Registry manifest

registry manifest /r/components/person.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add person fetches.

Browse the whole catalog in the gallery →.

Source (13 files)

Person.tsx

import { useFrame } from '@react-three/fiber'
import { CapsuleCollider, type RapierRigidBody, RigidBody } from '@react-three/rapier'
import {
  type InteractionAction,
  PlayerMotionContext,
  rng,
  sub,
  useInteraction,
  useWorld,
  type Vec3,
  type WorldComponentProps,
} from '@runek/core'
import { useContext, useEffect, useMemo, useRef, useState } from 'react'
import {
  CylinderGeometry,
  type Group,
  Mesh,
  MeshPhysicalMaterial,
  type Object3D,
  Quaternion,
  Group as ThreeGroup,
  TorusGeometry,
  Vector3,
} from 'three'
import { InteractionPrompt } from './Interactable'
import { Emote, SpeechBubble } from './person/Bubble'
import type { BuiltFigure, Lod } from './person/build'
import {
  type GaitState,
  PhysicsRouteDriver,
  RouteDriver,
  type WalkRoute,
} from './person/RouteDriver'
import { attach, createRig, skinnedMeshes } from './person/rig'
import { buildRoute, type RouteLoop, wanderPoints } from './person/route'
import { figureKey, requestFigure } from './person/scheduler'
import { ARM_SPREAD, shapeFigure } from './person/shape'
import { type PersonSkinName, resolveFigure } from './person/spec'
import type {
  PersonAccessory,
  PersonAge,
  PersonBodySpec,
  PersonBuild,
  PersonDetail,
  PersonEmote,
  PersonFaceSpec,
  PersonFacialHair,
  PersonGarment,
  PersonGender,
  PersonHair,
  PersonHairSpec,
  PersonHat,
  PersonKind,
  PersonOutfit,
  PersonPose,
  PersonSkin,
  PersonStyle,
} from './person/types'
import { Sign } from './Sign'

export {
  type PersonTrait,
  type PersonTraitSource,
  type ResolvedGarment,
  type ResolvedPerson,
  resolvePerson,
} from './person/resolve'
export { tripAt } from './person/route'
export { PERSON_COLORS, PERSON_SKINS, type PersonSkinName } from './person/spec'
export type {
  PersonAccessory,
  PersonAge,
  PersonBodySpec,
  PersonBuild,
  PersonDetail,
  PersonEmote,
  PersonFaceSpec,
  PersonFacialHair,
  PersonGarment,
  PersonGarmentType,
  PersonGender,
  PersonHair,
  PersonHairSpec,
  PersonHat,
  PersonKind,
  PersonNose,
  PersonOutfit,
  PersonPose,
  PersonSkin,
  PersonStyle,
} from './person/types'

export interface PersonProps extends WorldComponentProps {
  /** Proportion and face template: `stylized` (default) is an animated-film look with a big
   *  head and eyes, small features, and slender limbs; `realistic` keeps canonical human
   *  proportions; `anime` pushes the eyes and legs further. */
  style?: PersonStyle
  /** Role preset: fills `outfit`, `hat`, and `accessories` unless you set them. */
  kind?: PersonKind
  /** Silhouette cues only; every trait it influences stays overridable. Seeded when unset. */
  gender?: PersonGender
  /** Head-to-body ratio, stature, and (for `elder`) a stooped spine. */
  age?: PersonAge
  /** The body: `build`, skin `tone`, and `height`. The flat props below are shorthands for
   *  the same fields; when both are set, this object wins. */
  body?: PersonBodySpec
  /** The face, part by part: eye color and size, brow color and weight, nose shape, lip
   *  color and fullness, blush. */
  face?: PersonFaceSpec
  /** Clothing layers, inner to outer, each with its own type, color, and options. The order
   *  decides the fit: a shirt listed before the jeans is tucked in. Replaces `outfit`; `[]`
   *  is the bare base body (a smooth mannequin). */
  clothes?: PersonGarment[]
  /** Standing height, in units. Every other measurement derives from it. */
  height?: number
  /** Limb thickness and torso depth. Seeded when unset. */
  build?: PersonBuild
  /** Skin color; seeded from a curated set of tones when unset. */
  skinTone?: string
  /** Cosmetic loadout: a preset name from `PERSON_SKINS` (e.g. `"winter"`) or a custom
   *  `PersonSkin` bundle. Individual props win over it. Plain JSON either way, so an editor
   *  or an in-world UI can swap a figure's whole look by writing this one prop. */
  skin?: PersonSkinName | PersonSkin
  /** A style name, or `{ style, color }`. */
  hair?: PersonHair | PersonHairSpec
  hairColor?: string
  facialHair?: PersonFacialHair
  /** One-word outfit; expands to a `clothes` stack (e.g. `tunic`: trousers, tunic, shoes). */
  outfit?: PersonOutfit
  /** Outermost top's color; defaults to a seeded shade of a palette slot. A garment's own
   *  `color` wins over it. */
  topColor?: string
  /** Bottoms' color; defaults to a seeded shade of the palette's `woodDark`, `stone`, or `metal`. */
  bottomColor?: string
  /** Footwear color; defaults to the palette's `metal`. */
  shoeColor?: string
  hat?: PersonHat
  accessories?: PersonAccessory[]
  /** What the figure is doing when it isn't walking. `sit` and `type` need a seat under them
   *  (`type` is seated at a desk, fingers tapping); `work` leans over a counter; `play` works
   *  controls at waist height; `drink` holds a mug and sips from it now and then; `lie` lies on its
   *  back centered on `position`, head toward local -Z, so it takes a `Bed`'s position and
   *  rotation at mattress height. */
  pose?: PersonPose
  /** Walk these waypoints, relative to `position` and turning with `rotation`, in units.
   *  Height follows the ground; a waypoint's y lifts the figure above it. */
  patrol?: Vec3[]
  /** Wander a seeded loop of five points within this radius of `position`, in units. `patrol`
   *  wins when both are set. */
  wander?: number
  /** Walking speed, in units per second. Defaults by age: child 1.0, adult 1.3, elder 0.8. */
  speed?: number
  /** Seconds held at each waypoint. Walking always stands; the authored `pose` plays here. */
  pause?: number
  /** `loop` walks from the last waypoint back to the first; `pingpong` retraces the route. */
  loop?: RouteLoop
  /** A one-way trip through these waypoints, relative to `position` like `patrol`, setting off
   *  at `departAt`. Before then the figure waits at the first point facing its `rotation`; after
   *  the last it stays there, facing the way it came, in its `pose`. Wins over `patrol` and
   *  `wander`. Give a new trip a new `position` (the old one's end) and `departAt`. */
  route?: Vec3[]
  /** When the `route` trip sets off, in epoch milliseconds (`Date.now()`). Plain data, so every
   *  viewer sees the figure at the same point of its trip. Unset, the trip is long over. */
  departAt?: number
  /** Called once per trip (`route` + `departAt`) when the figure has arrived, on the first
   *  frame it has, so a trip that ended while the world was paused or unmounted still reports. */
  onArrive?: () => void
  /** Walk-cycle speed, in units per second, for a figure something else moves (a cart, a
   *  script). Unset, a route drives it, and a `Player`'s body walks at the avatar's speed. */
  gait?: number
  /** Breathing, weight shift, and blinking. */
  idle?: boolean
  /** Turn the head (and eyes) toward the player's avatar (or the camera, when no `Player` is
   *  mounted) when it comes within `lookRadius`. */
  lookAt?: boolean
  /** How close the avatar must be for `lookAt` to engage, in units. */
  lookRadius?: number
  /** Floating name above the head. */
  label?: string
  /** A short line in a speech bubble over the head (what the figure is saying or doing right
   *  now). Long lines wrap and are cut after a few. */
  bubble?: string
  /** A small animated sign over the head: `sleep` (rising z's), `alert` (a bouncing !), `think`
   *  (pulsing dots), `happy` (a heart), `coffee` (a steaming cup). */
  emote?: PersonEmote
  /** What the player can do with this figure (`Talk`, `Info`). When the avatar comes within
   *  `actionRadius` a prompt shows them over the head, with the key each world `controls`
   *  action is bound to; pressing one calls `onAction` with its `id`. Only the nearest figure
   *  (or other `Interactable`) in range shows a prompt and takes the key. */
  actions?: InteractionAction[]
  /** How close the avatar must come for `actions`, in units. */
  actionRadius?: number
  onAction?: (id: string) => void
  /** Capsule collider, so the figure is something you bump into. */
  collider?: boolean
  /** Render as a bare visual with no `RigidBody`: for a parent that owns the physics
   *  (e.g. as `Player`'s third-person avatar). When false, `collider` is ignored. */
  physics?: boolean
  /** `auto` builds the full-detail mesh only within ~10 units of the camera; `high` always;
   *  `low` never. */
  detail?: PersonDetail
}

const WALK_SPEED: Record<PersonAge, number> = { child: 1, adult: 1.3, elder: 0.8 }
/** Walking speed (m/s) at which the walk cycle reaches full swing. */
const CRUISE = 1.3
/** Full detail builds inside LOD_IN units of the camera and drops past LOD_OUT. */
const LOD_IN = 10
const LOD_OUT = 13
/** Seconds between sips for `drink`, and how long a sip takes. */
const SIP_EVERY = 7
const SIP = 1.8
const MAX_YAW = 1.2
const MAX_PITCH = 0.4
const EYE_REACH = 0.35

const NO_ACTIONS: InteractionAction[] = []

const isInside = (o: Object3D, ancestor: Object3D) => {
  for (let p = o.parent; p; p = p.parent) if (p === ancestor) return true
  return false
}

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)

/** Keep a build of `lod` while `want` holds; `null` until it's ready. */
function useBuild(
  spec: Parameters<typeof requestFigure>[0],
  key: string,
  lod: Lod,
  want: boolean,
  distance: { current: number },
) {
  const [built, setBuilt] = useState<BuiltFigure | null>(null)
  useEffect(() => {
    if (!want) {
      setBuilt(null)
      return
    }
    let live = true
    const cancel = requestFigure(
      spec,
      key,
      lod,
      () => distance.current,
      (b) => {
        if (live) setBuilt(b)
      },
    )
    return () => {
      live = false
      cancel()
    }
    // `spec` is identified by `key`.
  }, [key, lod, want, distance])
  return built && want ? built : null
}

/**
 * A procedural person: a sculpted, clothed, skinned figure generated from a seed.
 *
 * The body is one continuous surface grown from a few dozen smooth primitives on an 18-bone
 * skeleton (no seams at the joints), with a sculpted face, real eyelids, and eyeballs that
 * follow you. Clothes, hair, and hats are layers over it, each fitted to the body by offsetting
 * its surface, so any garment fits any build, age, or style. Skin weights come from the same
 * primitives, so the whole figure bends as one.
 *
 * Every trait resolves the same way: a part object (`body`, `face`, `hair`, `clothes`) wins, then
 * the flat shorthand prop, then the `skin` bundle, then the `kind` preset, then a seeded roll.
 * So `<Person seed={3} />` is a complete villager and each prop narrows it.
 *
 * `position` is the spawn point. Give it a `patrol` (waypoints) or a `wander` radius and it
 * walks: where it is on the route is a function of the clock and the seed, so every viewer sees
 * it in the same place, and it steps around the player and other walkers in its way. A `route`
 * with a `departAt` time is a one-way trip instead (to a desk, to bed), with `onArrive` when it
 * ends. It stands
 * on one capsule collider, kinematic while it walks. Pass `physics={false}` for a bare visual,
 * which is how it becomes `Player`'s third-person body (walking in step with the avatar):
 * `<Player><Person physics={false} height={1.3} position={[0, -0.65, 0]} /></Player>`.
 *
 * Meshes build in the background a few milliseconds per frame, coarse first; the full-detail
 * mesh only builds near the camera. Identical figures share one build.
 *
 * `resolvePerson(props)` reports what every trait resolves to and where it came from (prop,
 * skin, kind, seed, or default), so an editor can show what the seed chose; `PERSON_COLORS`
 * holds the curated skin, hair, and eye colors the seed rolls from.
 */
export function Person({
  position,
  rotation = [0, 0, 0],
  seed = 1,
  style = 'stylized',
  kind = 'villager',
  gender,
  age = 'adult',
  body,
  face,
  clothes,
  height,
  build,
  skinTone,
  skin,
  hair,
  hairColor,
  facialHair,
  outfit,
  topColor,
  bottomColor,
  shoeColor,
  hat,
  accessories,
  pose = 'stand',
  patrol,
  wander,
  speed,
  pause = 1.5,
  loop = 'loop',
  route: trip,
  departAt = 0,
  onArrive,
  gait,
  idle = true,
  lookAt = true,
  lookRadius = 5,
  label,
  bubble,
  emote,
  actions,
  actionRadius = 2,
  onAction,
  collider = true,
  physics = true,
  detail = 'auto',
}: PersonProps) {
  const { unit, palette, ground, player } = useWorld()
  const avatarMotion = useContext(PlayerMotionContext)
  // A placed figure stands on the world's ground baseline; a bare visual is positioned by
  // whatever parent owns it (a `Player` capsule, a vehicle), so it starts at its own origin.
  const at: Vec3 = position ?? (physics ? [0, ground, 0] : [0, 0, 0])

  const traits = JSON.stringify([
    seed,
    style,
    kind,
    gender,
    age,
    body,
    face,
    clothes,
    height,
    build,
    skinTone,
    skin,
    hair,
    hairColor,
    facialHair,
    outfit,
    topColor,
    bottomColor,
    shoeColor,
    hat,
    accessories,
  ])
  const { spec, motion } = useMemo(
    () =>
      resolveFigure(
        {
          seed,
          style,
          kind,
          gender,
          age,
          body,
          face,
          clothes,
          height,
          build,
          skinTone,
          skin,
          hair,
          hairColor,
          facialHair,
          outfit,
          topColor,
          bottomColor,
          shoeColor,
          hat,
          accessories,
        },
        palette,
        unit,
      ),
    [traits, palette, unit],
  )
  const key = useMemo(() => figureKey(spec), [spec])
  // Joints and landmarks only: cheap, and the same for every level of detail.
  const shape = useMemo(() => shapeFigure(spec), [key])
  const rig = useMemo(() => createRig(shape.joints), [shape])
  const marks = shape.marks

  // The route is node-local geometry like any other: waypoints scale by `unit` and turn with the
  // node. Wander points come from their own seed stream so they don't reshuffle the figure. A
  // trip's clock starts at `departAt`; a cycle's at a seeded point, so walkers don't march in step.
  const patrolKey = patrol && JSON.stringify(patrol)
  const tripKey = trip && JSON.stringify(trip)
  const route = useMemo<WalkRoute | null>(() => {
    const points = trip ?? patrol ?? (wander ? wanderPoints(wander, sub(seed, 1)) : undefined)
    if (!points || (!trip && points.length < 2)) return null
    const built = buildRoute(
      points.map(([x, y, z]) => [x * unit, y * unit, z * unit]),
      { speed: (speed ?? WALK_SPEED[age]) * unit, pause, loop: trip ? 'once' : loop },
    )
    if (trip) return { ...built, phase: -departAt / 1000 }
    if (!built.period) return null
    return { ...built, phase: rng(sub(seed, 2))() * built.period }
  }, [tripKey, departAt, patrolKey, wander, seed, speed, age, pause, loop, unit])
  const arrive = useRef(onArrive)
  arrive.current = onArrive
  const handleArrive = useMemo(() => () => arrive.current?.(), [])

  // ---- level of detail -------------------------------------------------------------------------
  const distance = useRef(Number.POSITIVE_INFINITY)
  const [close, setClose] = useState(false)
  const wantNear = detail === 'high' || (detail === 'auto' && close)
  // The coarse build always comes first (it's quick), so a figure is never missing for long.
  const far = useBuild(spec, key, 'far', true, distance)
  const near = useBuild(spec, key, 'near', wantNear, distance)
  const active = near ?? far

  const meshes = useMemo(() => (active ? skinnedMeshes(active, rig) : null), [active, rig])
  useEffect(() => {
    if (!meshes) return
    for (const m of meshes.meshes) rig.group.add(m)
    return meshes.dispose
  }, [meshes, rig])

  const parts = useMemo(
    () =>
      attach(
        rig,
        shape,
        shape.joints,
        spec.colors.eye,
        spec.colors.skin,
        spec.colors.metal,
        spec.colors.wood,
      ),
    [rig, shape, spec.colors],
  )
  useEffect(() => parts.dispose, [parts])
  const detailed = active?.lod === 'near'
  const shown = active !== null
  useEffect(() => {
    for (const l of parts.lids) l.visible = detailed
    for (const e of parts.eyes) e.visible = shown
    for (const o of parts.props) o.visible = shown
  }, [parts, detailed, shown])

  const root = useRef<Group>(null)
  const focused = useInteraction(root, {
    actions: actions ?? NO_ACTIONS,
    radius: actionRadius,
    onAction,
  })
  const nameTag = useRef<Group>(null)
  const nodeFrame = useRef<Group>(null)
  const walker = useRef<Group>(null)
  const facing = useRef<Group>(null)
  const bodyRef = useRef<RapierRigidBody>(null)
  const scratch = useMemo(() => new Vector3(), [])
  const walk = useRef<GaitState>({ speed: 0, distance: 0 })
  // Eased blend weights: `stride` is how far into the walk cycle the limbs are (0 standing, 1
  // full swing); `posed` how far into the authored pose (walking always stands).
  const blend = useRef({ stride: 0, posed: 1 })

  const H = spec.height
  const p = useMemo(() => {
    const sit = pose === 'sit' || pose === 'type'
    const lean: Partial<Record<PersonPose, number>> = {
      work: 0.42,
      lean: -0.14,
      type: 0.14,
      play: 0.1,
      sit: 0.06,
    }
    const armX: Partial<Record<PersonPose, number>> = {
      work: -0.7,
      type: -0.5,
      play: -0.45,
      sit: -0.25,
      drink: -0.12,
    }
    const elbow: Partial<Record<PersonPose, number>> = {
      work: -0.6,
      type: -1.15,
      play: -1.25,
      sit: -0.5,
      drink: -0.35,
      lie: -0.08,
    }
    return {
      sit,
      lie: pose === 'lie',
      drop: sit ? -(marks.hipY - marks.thigh * 0.92) : 0,
      spineX: motion.stoop * (pose === 'lie' ? 0 : 1) + (lean[pose] ?? 0),
      armX: armX[pose] ?? 0,
      armZ: pose === 'lean' ? 0.1 : pose === 'lie' ? 0.16 : 0.02,
      elbow: elbow[pose] ?? -0.18,
      thighX: sit ? -Math.PI / 2 : 0,
      shinX: sit ? Math.PI / 2.1 : 0,
      wave: pose === 'wave',
      drink: pose === 'drink',
      /** Finger tapping (keys, buttons): how fast. */
      tap: pose === 'type' ? 17 : pose === 'play' ? 9 : 0,
    }
  }, [pose, marks, motion.stoop])

  // A mug for `drink`, held in the right hand and kept upright whatever the wrist does.
  const mug = useMemo(() => {
    const k = H / 1.7
    const body = new CylinderGeometry(0.036 * k, 0.032 * k, 0.09 * k, 14)
    const handle = new TorusGeometry(0.022 * k, 0.006 * k, 6, 12, Math.PI)
    const mat = new MeshPhysicalMaterial({ color: '#efe9df', roughness: 0.35, clearcoat: 0.4 })
    const g = new ThreeGroup()
    const cup = new Mesh(body, mat)
    const ear = new Mesh(handle, mat)
    ear.rotation.z = -Math.PI / 2
    ear.position.set(0, 0, -0.036 * k)
    ear.rotation.y = Math.PI / 2
    cup.castShadow = true
    g.add(cup, ear)
    g.position.set(0.012 * k, -0.075 * k, 0.03 * k)
    const dispose = () => {
      body.dispose()
      handle.dispose()
      mat.dispose()
    }
    return { group: g, dispose }
  }, [H])
  useEffect(() => {
    if (!p.drink) return
    const hand = rig.bones['hand.R']
    hand.add(mug.group)
    return () => {
      hand.remove(mug.group)
    }
  }, [p.drink, rig, mug])
  useEffect(() => mug.dispose, [mug])
  const quat = useMemo(() => ({ a: new Quaternion(), b: new Quaternion() }), [])

  useFrame((state, dt) => {
    const t = state.clock.elapsedTime
    const k = 1 - Math.exp(-7 * dt)
    const b = rig.bones

    if (root.current) {
      distance.current =
        root.current.getWorldPosition(scratch).distanceTo(state.camera.position) / unit
      if (detail === 'auto') {
        if (!close && distance.current < LOD_IN) setClose(true)
        else if (close && distance.current > LOD_OUT) setClose(false)
      }
    }

    // Walking speed comes from the route, else an explicit `gait`, else the carrying `Player`.
    // Off-route, distance is integrated so the stride phase still tracks the feet.
    const w = walk.current
    if (!route) {
      w.speed = gait ?? avatarMotion?.current.speed ?? 0
      w.distance += w.speed * dt
    }
    const bl = blend.current
    const moving = w.speed > 0.05 * unit
    bl.stride += (Math.min(w.speed / (CRUISE * unit), 1) - bl.stride) * (1 - Math.exp(-10 * dt))
    bl.posed += ((moving ? 0 : 1) - bl.posed) * (1 - Math.exp(-8 * dt))
    const s = bl.stride
    const q = bl.posed
    const waving = p.wave && q > 0.5
    // Lying down turns the whole figure onto its back, centered on the origin, back on the
    // surface, head toward -Z.
    const lie = p.lie ? q : 0
    rig.group.rotation.x = -(Math.PI / 2) * lie
    rig.group.position.set(0, 0.075 * H * lie, (H / 2) * lie)
    const tap = (side: number) => (p.tap ? Math.sin(t * p.tap + side) * 0.035 * q : 0)
    const cyc = (t + motion.phase * 3) % SIP_EVERY
    const sip = p.drink && cyc < SIP ? Math.sin((cyc / SIP) * Math.PI) * q : 0
    // Stride phase from distance walked: one full cycle (two steps) per 0.8 body heights.
    const phase = (w.distance / (0.8 * H)) * Math.PI * 2
    const swing = Math.sin(phase) * s * Math.min(0.3 + 0.2 * (w.speed / unit), 0.7)
    const bob = s * 0.012 * H * (Math.abs(Math.cos(phase)) - 0.6)
    const breath = idle ? Math.sin(t * 1.6 + motion.phase) : 0
    const sway = idle ? Math.sin(t * 0.42 + motion.phase) * (1 - s) : 0

    b.root.position.y = p.drop * q + breath * 0.003 * H + bob
    b.pelvis.rotation.z = sway * 0.022
    const lean = motion.stoop + (p.spineX - motion.stoop) * q + s * 0.05
    b.spine.rotation.x = lean * 0.45
    b.chest.rotation.set(lean * 0.55 + breath * 0.012, swing * 0.18, -sway * 0.012)
    // Opposite arm to leg: an arm swings back as its leg reaches forward.
    b['upperArm.L'].rotation.set(p.armX * q - sway * 0.06 + swing * 0.7, 0, p.armZ - ARM_SPREAD)
    b['foreArm.L'].rotation.set(p.elbow * q - 0.18 * (1 - q) - s * 0.25 + tap(0), 0, 0)
    if (p.drink && q > 0.05) {
      // Mug at the chest, lifted to the lips for a sip.
      b['upperArm.R'].rotation.set((-0.35 - 0.3 * sip) * q, 0, ARM_SPREAD + (0.4 + 0.12 * sip) * q)
      b['foreArm.R'].rotation.set((-1.75 - 0.6 * sip) * q - 0.18 * (1 - q), 0, 0)
    } else if (waving) {
      // Upper arm out just above level, forearm raised and rocking at the elbow.
      b['upperArm.R'].rotation.set(-0.25, 0, ARM_SPREAD - 1.75)
      b['foreArm.R'].rotation.set(0, 0, -1.15 - Math.sin(t * 6) * 0.35)
    } else {
      b['upperArm.R'].rotation.set(p.armX * q + sway * 0.06 - swing * 0.7, 0, ARM_SPREAD - p.armZ)
      b['foreArm.R'].rotation.set(p.elbow * q - 0.18 * (1 - q) - s * 0.25 + tap(2.1), 0, 0)
    }
    if (p.drink) {
      // Undo the wrist's turn so the mug stays upright, tipping only as it reaches the lips.
      rig.group.updateMatrixWorld()
      rig.group.getWorldQuaternion(quat.a).invert()
      b['hand.R'].getWorldQuaternion(quat.b)
      mug.group.quaternion.copy(quat.a.multiply(quat.b)).invert()
      mug.group.rotateX(0.5 * sip)
    }
    // Legs: the thigh swings (negative X is forward), and the knee flexes while that leg travels
    // forward, which is what lifts the foot clear of the ground.
    const knee = (side: number) => s * (0.12 + 0.6 * Math.max(0, Math.cos(phase + side)))
    b['thigh.L'].rotation.x = p.thighX * q - swing
    b['thigh.R'].rotation.x = p.thighX * q + swing
    b['shin.L'].rotation.x = p.shinX * q + knee(0)
    b['shin.R'].rotation.x = p.shinX * q + knee(Math.PI)
    b['foot.L'].rotation.x = -knee(0) * 0.3
    b['foot.R'].rotation.x = -knee(Math.PI) * 0.3

    // Blink: the lid sweeps down over the eye and back.
    if (parts.lids.length) {
      const blink = (t + motion.blinkPhase) % motion.blinkEvery
      const closed = idle && blink < 0.14 ? Math.sin((blink / 0.14) * Math.PI) : 0
      for (const lid of parts.lids) lid.rotation.x = -0.95 + closed * 1.75
    }

    // Head tracking: the target in the head's own parent space gives yaw and pitch directly. The
    // target is the player's avatar when a `Player` is mounted (in third person the camera trails
    // behind it), else the camera. Outside the radius, or behind the shoulder, the head eases back
    // to neutral; the eyes lead the head and cover what it can't. A figure that *is* the avatar's
    // body has nobody to watch.
    const h = b.head
    if (h.rotation.order !== 'YXZ') h.rotation.order = 'YXZ'
    let yaw = idle ? sway * 0.05 : 0
    let pitch = 0
    let wanted = 0
    let tracking = false
    const avatar = player?.current
    const self = avatar?.parent != null && isInside(h, avatar.parent)
    if (lookAt && h.parent && !self && !p.lie) {
      if (avatar) avatar.getWorldPosition(scratch)
      else scratch.copy(state.camera.position)
      h.parent.worldToLocal(scratch).sub(h.position)
      const flat = Math.hypot(scratch.x, scratch.z)
      wanted = Math.atan2(scratch.x, scratch.z)
      if (scratch.length() < lookRadius * unit && Math.abs(wanted) < MAX_YAW + EYE_REACH) {
        tracking = true
        yaw = clamp(wanted, -MAX_YAW, MAX_YAW)
        pitch = clamp(-Math.atan2(scratch.y, flat), -MAX_PITCH, MAX_PITCH)
      }
    }
    h.rotation.y += (yaw - h.rotation.y) * k
    h.rotation.x += (pitch - 0.25 * sip - h.rotation.x) * k
    const eyeYaw = tracking ? clamp(wanted - h.rotation.y, -EYE_REACH, EYE_REACH) : 0
    for (const e of parts.eyes) e.rotation.y += (eyeYaw - e.rotation.y) * Math.min(1, k * 3)

    // Keep the name legible from wherever it's read.
    if (nameTag.current?.parent) {
      scratch.copy(state.camera.position)
      nameTag.current.parent.worldToLocal(scratch)
      nameTag.current.rotation.y = Math.atan2(scratch.x, scratch.z)
    }
  })

  // Where floating things go: over the head, which for a lying figure is near the surface at -Z.
  const overhead: Vec3 = p.lie ? [0, 0.32 * H, -0.42 * H] : [0, H + marks.headH * 0.55 + p.drop, 0]
  // Stacked over the head, bottom to top: name, bubble, emote, action prompt.
  const [bubbleH, setBubbleH] = useState(0)
  const above = (y: number): Vec3 => [overhead[0], overhead[1] + y, overhead[2]]
  const bubbleY = (label ? 0.2 : 0.05) * unit
  const emoteY = bubbleY + (bubble ? bubbleH + 0.05 * unit : 0.08 * unit)
  const promptY = emoteY + (emote ? 0.3 * unit : 0) + (bubble || emote ? 0.04 * unit : 0)
  const figure = (
    <group ref={root}>
      <primitive object={rig.group} />
      {label && (
        <group ref={nameTag} position={overhead}>
          <Sign variant="body" size={0.13} color={palette.accent}>
            {label}
          </Sign>
        </group>
      )}
      {bubble && (
        <SpeechBubble
          text={bubble}
          position={above(bubbleY)}
          onHeight={(h) => setBubbleH((prev) => (Math.abs(prev - h) > 0.002 ? h : prev))}
        />
      )}
      {emote && <Emote kind={emote} position={above(emoteY)} />}
      {focused && actions && (
        <InteractionPrompt
          actions={actions}
          position={above(bubble || emote ? promptY : (label ? 0.26 : 0.08) * unit)}
        />
      )}
    </group>
  )

  // One capsule spanning the standing (or seated) body: an obstacle you bump into, not a
  // collider per limb (CONTRACT §5). A walking figure keeps the standing one throughout.
  const seated = p.sit && !route
  const bottom = seated ? H * 0.06 : 0
  const top = H + (seated ? p.drop : 0)
  const radius = Math.min(0.105 * H, (top - bottom) / 2)
  const half = Math.max((top - bottom) / 2 - radius, 0.01 * H)
  const driven = route && {
    route,
    frame: nodeFrame,
    facing,
    gait: walk,
    radius,
    waist: marks.waistY,
    unit,
    onArrive: handleArrive,
  }

  if (!physics) {
    return (
      <group ref={nodeFrame} position={at} rotation={rotation}>
        <group ref={walker}>
          <group ref={facing}>{figure}</group>
        </group>
        {driven && (
          <RouteDriver
            {...driven}
            move={(to) => walker.current?.position.copy(nodeFrame.current?.worldToLocal(to) ?? to)}
          />
        )}
      </group>
    )
  }

  // A walking figure's body is kinematic, moved by its route; the node frame it walks in is a
  // bare sibling, since the body itself leaves the spawn point. A standing one stays fixed.
  // `excludeEcctrlRay` keeps the avatar from treating a walker as a moving platform to ride.
  return (
    <>
      {driven && (
        <>
          <group ref={nodeFrame} position={at} rotation={rotation} />
          <PhysicsRouteDriver {...driven} body={bodyRef} />
        </>
      )}
      <RigidBody
        key={driven ? 'walk' : 'stand'}
        ref={bodyRef}
        type={driven ? 'kinematicPosition' : 'fixed'}
        userData={driven ? { excludeEcctrlRay: true } : undefined}
        colliders={false}
        position={at}
        rotation={rotation}
      >
        {collider &&
          (p.lie && !route ? (
            <CapsuleCollider
              args={[Math.max(H / 2 - radius, 0.01 * H), radius]}
              rotation={[Math.PI / 2, 0, 0]}
              position={[0, radius, 0]}
            />
          ) : (
            <CapsuleCollider args={[half, radius]} position={[0, (top + bottom) / 2, 0]} />
          ))}
        <group ref={facing}>{figure}</group>
      </RigidBody>
    </>
  )
}

Person.groundSitting = true

person/Bubble.tsx

import { Billboard } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo, useRef, useState } from 'react'
import { type Group, type Mesh, Shape, ShapeGeometry } from 'three'
import { Sign } from '../Sign'
import type { PersonEmote } from './types'

const PAPER = '#fbf8f1'
const INK = '#2b2a2e'
const MAX_CHARS = 90
const ALERT = '#e0573f'
const THINK = '#56607a'

/** A rounded box with a little tail at the bottom middle, its origin at the tail's tip. */
function bubbleShape(w: number, h: number, r: number, tail: number) {
  const s = new Shape()
  const x0 = -w / 2
  const y0 = tail
  s.moveTo(0, 0)
  s.lineTo(-tail * 0.8, y0)
  s.lineTo(x0 + r, y0)
  s.quadraticCurveTo(x0, y0, x0, y0 + r)
  s.lineTo(x0, y0 + h - r)
  s.quadraticCurveTo(x0, y0 + h, x0 + r, y0 + h)
  s.lineTo(-x0 - r, y0 + h)
  s.quadraticCurveTo(-x0, y0 + h, -x0, y0 + h - r)
  s.lineTo(-x0, y0 + r)
  s.quadraticCurveTo(-x0, y0, -x0 - r, y0)
  s.lineTo(tail * 0.8, y0)
  s.closePath()
  return new ShapeGeometry(s, 6)
}

export interface SpeechBubbleProps {
  text: string
  /** Where the tail points to, the bubble rising above it. */
  position?: Vec3
  /** Cap height of the text, in units. */
  size?: number
  /** Wrap width, in units. */
  maxWidth?: number
  /** Reports the bubble's height so things can stack above it. */
  onHeight?: (height: number) => void
}

/** A short line of speech in a rounded bubble, always facing the camera. Long text wraps, and
 *  past a few lines it's cut with an ellipsis: a bubble is a glance, not a page. */
export function SpeechBubble({
  text,
  position,
  size = 0.075,
  maxWidth = 1.5,
  onHeight,
}: SpeechBubbleProps) {
  const { unit } = useWorld()
  const [box, setBox] = useState<[number, number] | null>(null)
  const line = text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS - 1)}…` : text
  const pad = size * unit * 0.9
  const tail = size * unit * 0.9
  const w = (box?.[0] ?? 0) + pad * 2
  const h = (box?.[1] ?? 0) + pad * 1.4
  const geo = useMemo(
    () => (box ? bubbleShape(w, h, Math.min(h / 2, pad * 1.4), tail) : null),
    [box, w, h, pad, tail],
  )
  return (
    <Billboard position={position}>
      {geo && (
        <mesh geometry={geo} renderOrder={1}>
          <meshBasicMaterial color={PAPER} toneMapped={false} />
        </mesh>
      )}
      <Sign
        position={[0, tail + h / 2, 0.002 * unit]}
        variant="body"
        size={size}
        color={INK}
        maxWidth={maxWidth}
        onBounds={(bw, bh) => {
          setBox((prev) => (prev && prev[0] === bw && prev[1] === bh ? prev : [bw, bh]))
          onHeight?.(bh + pad * 1.4 + tail)
        }}
      >
        {line}
      </Sign>
    </Billboard>
  )
}

function heartGeometry(s: number) {
  const shape = new Shape()
  shape.moveTo(0, -s * 0.9)
  shape.bezierCurveTo(-s * 0.2, -s * 0.6, -s * 1.1, -s * 0.2, -s * 1.0, s * 0.35)
  shape.bezierCurveTo(-s * 0.9, s * 0.95, -s * 0.15, s * 1.0, 0, s * 0.45)
  shape.bezierCurveTo(s * 0.15, s * 1.0, s * 0.9, s * 0.95, s * 1.0, s * 0.35)
  shape.bezierCurveTo(s * 1.1, -s * 0.2, s * 0.2, -s * 0.6, 0, -s * 0.9)
  return new ShapeGeometry(shape, 8)
}

export interface EmoteProps {
  kind: PersonEmote
  position?: Vec3
  /** Overall size, in units. */
  size?: number
}

/**
 * A small animated sign of a state, drawn from primitives and the world font (no icons):
 * `sleep` rising z's, `alert` a bouncing !, `think` three pulsing dots, `happy` a beating heart,
 * `coffee` a steaming cup.
 */
export function Emote({ kind, position, size = 0.17 }: EmoteProps) {
  const { unit, palette } = useWorld()
  const s = size * unit
  const parts = useRef<(Group | Mesh | null)[]>([])
  const heart = useMemo(() => (kind === 'happy' ? heartGeometry(s * 0.5) : null), [kind, s])

  useFrame(({ clock }) => {
    const t = clock.elapsedTime
    const ps = parts.current
    if (kind === 'sleep') {
      ps.forEach((g, i) => {
        if (!g) return
        const k = (t * 0.45 + i / 3) % 1
        g.position.set(Math.sin(k * 5 + i) * s * 0.1 + k * s * 1.3, k * s * 2.4, 0)
        g.scale.setScalar(0.5 + k * 0.7)
        g.visible = k < 0.92
      })
    } else if (kind === 'alert') {
      const g = ps[0]
      if (g) g.position.y = Math.abs(Math.sin(t * 5)) * s * 0.35
    } else if (kind === 'think') {
      ps.forEach((g, i) => {
        g?.scale.setScalar(0.7 + 0.45 * Math.max(0, Math.sin(t * 4 - i * 0.9)))
      })
    } else if (kind === 'happy') {
      const b = 1 + 0.12 * Math.max(0, Math.sin(t * 7)) * Math.max(0, Math.sin(t * 3.5))
      ps[0]?.scale.setScalar(b)
    } else if (kind === 'coffee') {
      ps.forEach((g, i) => {
        if (!g || i === 0) return
        const k = (t * 0.5 + i / 3) % 1
        g.position.set(Math.sin(k * 6 + i * 2) * s * 0.08, s * 0.35 + k * s * 0.9, 0)
        g.scale.setScalar((1 - k) * 0.9 + 0.1)
      })
    }
  })

  const set = (i: number) => (o: Group | Mesh | null) => {
    parts.current[i] = o
  }

  return (
    <Billboard position={position}>
      {kind === 'sleep' &&
        [0, 1, 2].map((i) => (
          <group key={i} ref={set(i)}>
            <Sign variant="display" size={size} color="#9cc1f2" outline="#1d2a44">
              z
            </Sign>
          </group>
        ))}
      {kind === 'alert' && (
        <group ref={set(0)}>
          <Sign variant="display" size={size * 1.6} color={ALERT} outline="#3a1610">
            !
          </Sign>
        </group>
      )}
      {kind === 'think' &&
        [-1, 0, 1].map((x, i) => (
          <mesh key={x} ref={set(i)} position={[x * s * 0.42, 0, 0]}>
            <sphereGeometry args={[s * 0.13, 12, 8]} />
            <meshBasicMaterial color={THINK} toneMapped={false} />
          </mesh>
        ))}
      {kind === 'happy' && heart && (
        <mesh ref={set(0)} geometry={heart}>
          <meshBasicMaterial color="#e2566f" toneMapped={false} />
        </mesh>
      )}
      {kind === 'coffee' && (
        <>
          <group ref={set(0)}>
            <mesh position={[0, s * 0.12, 0]}>
              <cylinderGeometry args={[s * 0.24, s * 0.2, s * 0.32, 16]} />
              <meshBasicMaterial color={PAPER} toneMapped={false} />
            </mesh>
            <mesh position={[s * 0.27, s * 0.13, 0]}>
              <torusGeometry args={[s * 0.09, s * 0.03, 6, 12]} />
              <meshBasicMaterial color={PAPER} toneMapped={false} />
            </mesh>
            <mesh position={[0, s * 0.27, 0.001 * unit]}>
              <planeGeometry args={[s * 0.44, s * 0.05]} />
              <meshBasicMaterial color={palette.woodDark} toneMapped={false} />
            </mesh>
          </group>
          {[1, 2, 3].map((i) => (
            <mesh key={i} ref={set(i)}>
              <sphereGeometry args={[s * 0.07, 8, 6]} />
              <meshBasicMaterial color="#e9eef2" transparent opacity={0.75} toneMapped={false} />
            </mesh>
          ))}
        </>
      )}
    </Billboard>
  )
}

person/RouteDriver.tsx

import { useFrame } from '@react-three/fiber'
import { type RapierRigidBody, useRapier } from '@react-three/rapier'
import { useGround, useWorld, type Walker } from '@runek/core'
import { type RefObject, useEffect, useMemo, useRef } from 'react'
import { type Object3D, Vector3 } from 'three'
import type { Route } from './route'

/** A route plus the seconds added to the wall clock to sample it: a seeded offset into a cycle,
 *  so figures sharing a route don't march in step, or minus the departure time for a trip. */
export interface WalkRoute extends Route {
  phase: number
}

/** What the driver tells the body each frame: how fast it moves and how far it has walked. */
export interface GaitState {
  speed: number
  distance: number
}

/** True when something solid lies within `reach` of `from` along the horizontal (dx, dz). */
export type SideCheck = (from: Vector3, dx: number, dz: number, reach: number) => boolean

export interface RouteDriverProps {
  route: WalkRoute
  /** The node's own frame (spawn point + rotation); waypoints are local to it. */
  frame: RefObject<Object3D | null>
  /** Put the figure's origin at this world point. */
  move: (world: Vector3) => void
  /** Turned toward the heading, in the node frame. */
  facing: RefObject<Object3D | null>
  gait: { current: GaitState }
  /** Body radius and the height side checks run at, in world units. */
  radius: number
  waist: number
  unit: number
  blocked?: SideCheck
  /** Called once when a trip is over, on the first frame it is (even if that was long ago). */
  onArrive?: () => void
}

// How far ahead a walker looks for someone in its way, and the gap it keeps (in meters).
const LOOK_AHEAD = 2.5
const MARGIN = 0.25
const MAX_DETOUR = 1.6
const AVATAR_RADIUS = 0.3
// Route seconds a held-up figure makes back per second, once the way clears.
const CATCH_UP = 0.25
// Seconds between side checks while a detour holds.
const RECHECK = 0.2
// Closest a walker lets itself come to the avatar beyond touching: a kinematic body never gives
// way, so walking into the player would shove it (into the ground, on a slope).
const PERSONAL_SPACE = 0.1

const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a))

/**
 * Walk a figure along its route. The base position is the route sampled at wall-clock time, so
 * every viewer agrees on it; height follows the ground under it (relative to the spawn point, so a
 * figure placed on a deck the ground index doesn't know stays on it). Detours ride on top: the
 * figure arcs around the avatar and other walkers ahead, passing on the right when head-on, and
 * stops (falling behind its clock, then catching up) only when both sides are blocked.
 */
export function RouteDriver({
  route,
  frame,
  move,
  facing,
  gait,
  radius,
  waist,
  unit,
  blocked,
  onArrive,
}: RouteDriverProps) {
  const { player, walkers } = useWorld()
  const groundAt = useGround()
  const self = useMemo<Walker>(() => ({ x: 0, z: 0, radius }), [radius])
  const state = useRef({
    lag: 0,
    offset: 0,
    target: 0,
    checked: 0,
    started: false,
    arrived: null as Route | null,
  })
  const last = useMemo(() => new Vector3(), [])
  const v = useMemo(
    () => ({
      spawn: new Vector3(),
      at: new Vector3(),
      dir: new Vector3(),
      other: new Vector3(),
      avatar: new Vector3(),
    }),
    [],
  )

  useEffect(() => {
    if (!walkers) return
    walkers.add(self)
    return () => {
      walkers.delete(self)
    }
  }, [walkers, self])

  useFrame((_, delta) => {
    const node = frame.current
    if (!node) return
    const dt = Math.min(delta, 0.1)
    const s = state.current
    const t = Date.now() / 1000 + route.phase - s.lag
    const p = route.sample(t)

    node.updateWorldMatrix(true, false)
    v.spawn.setFromMatrixPosition(node.matrixWorld)
    v.at.set(p.x, p.y, p.z)
    node.localToWorld(v.at)
    self.x = v.at.x
    self.z = v.at.z

    v.dir.set(Math.sin(p.heading), 0, Math.cos(p.heading)).transformDirection(node.matrixWorld)
    v.dir.y = 0
    v.dir.normalize()
    const dx = v.dir.x
    const dz = v.dir.z

    // The obstacle that needs the widest berth: its lateral position (right is +), the gap to
    // keep, and whether it's close enough that a blocked detour means stopping.
    let want = 0
    let side = 0
    let clear = 0
    let weight = 0
    let close = false
    const consider = (ox: number, oz: number, oradius: number) => {
      const rx = ox - v.at.x
      const rz = oz - v.at.z
      const ahead = rx * dx + rz * dz
      const lateral = -rx * dz + rz * dx
      const gap = radius + oradius + MARGIN * unit
      const reach = LOOK_AHEAD * unit
      if (Math.abs(lateral) >= gap || ahead > reach || ahead < -gap) return
      // Full berth by the time it draws level, released once it's a body length past.
      const w = ahead > gap ? (reach - ahead) / (reach - gap) : ahead >= 0 ? 1 : 1 + ahead / gap
      const ease = w * w * (3 - 2 * w)
      const o = (lateral > 0.15 * gap ? lateral - gap : lateral + gap) * ease
      if (Math.abs(o) > Math.abs(want)) {
        want = o
        side = lateral
        clear = gap
        weight = ease
        close = ahead < gap + 0.3 * unit
      }
    }
    const avatar = player?.current
    if (avatar) avatar.getWorldPosition(v.avatar)
    if (p.speed > 0) {
      if (avatar) consider(v.avatar.x, v.avatar.z, AVATAR_RADIUS * unit)
      if (walkers) for (const w of walkers) if (w !== self) consider(w.x, w.z, w.radius)
    }

    let target = Math.min(Math.max(want, -MAX_DETOUR * unit), MAX_DETOUR * unit)
    let hold = false
    if (blocked && Math.abs(target) > 0.05 * unit) {
      s.checked -= dt
      if (s.checked <= 0 || Math.sign(target) !== Math.sign(s.target)) {
        s.checked = RECHECK
        v.other.set(v.at.x, v.at.y + waist, v.at.z)
        const sign = Math.sign(target)
        if (blocked(v.other, -dz * sign, dx * sign, Math.abs(target) + radius)) {
          const alt = (target > 0 ? side - clear : side + clear) * weight
          const altSign = Math.sign(alt)
          target = blocked(v.other, -dz * altSign, dx * altSign, Math.abs(alt) + radius) ? 0 : alt
          hold = target === 0 && close
        }
        s.target = target
      } else {
        target = s.target
        hold = target === 0 && close
      }
    } else {
      s.target = target
    }

    s.offset += (target - s.offset) * (1 - Math.exp(-5 * dt))
    v.at.x += -dz * s.offset
    v.at.z += dx * s.offset
    v.at.y += groundAt(v.at.x, v.at.z) - groundAt(v.spawn.x, v.spawn.z)

    // Never step into the avatar: if this frame would close on it inside touching range, stay
    // put and let the route clock fall behind instead.
    if (avatar && s.started) {
      const touch = radius + (AVATAR_RADIUS + PERSONAL_SPACE) * unit
      const next = Math.hypot(v.at.x - v.avatar.x, v.at.z - v.avatar.z)
      const now = Math.hypot(last.x - v.avatar.x, last.z - v.avatar.z)
      if (next < touch && next < now) {
        hold = true
        v.at.copy(last)
      }
    }
    // Held time is real frame time, not the clamped step, so the route can't creep on while held.
    s.lag = hold ? s.lag + delta : Math.max(0, s.lag - CATCH_UP * dt)
    last.copy(v.at)
    move(v.at)

    const f = facing.current
    if (f) {
      if (!s.started) f.rotation.y = p.heading
      else f.rotation.y += wrap(p.heading - f.rotation.y) * (1 - Math.exp(-6 * dt))
    }
    s.started = true
    gait.current.speed = hold ? 0 : p.speed
    gait.current.distance = p.distance

    if (route.once && t >= route.period && s.arrived !== route) {
      s.arrived = route
      onArrive?.()
    }
  })

  return null
}

/** `RouteDriver` for a figure with its own kinematic body: it moves the body, and checks the
 *  side it steps to against the physics world. */
export function PhysicsRouteDriver({
  body,
  ...props
}: Omit<RouteDriverProps, 'move' | 'blocked'> & { body: RefObject<RapierRigidBody | null> }) {
  const { world, rapier } = useRapier()
  const move = useMemo(() => (at: Vector3) => body.current?.setNextKinematicTranslation(at), [body])
  const blocked = useMemo<SideCheck>(
    () => (from, dx, dz, reach) => {
      const ray = new rapier.Ray(from, { x: dx, y: 0, z: dz })
      // Static geometry only: people in the way are the detour's business, and a ray starting
      // inside the avatar would call both sides blocked.
      const flags = rapier.QueryFilterFlags.ONLY_FIXED | rapier.QueryFilterFlags.EXCLUDE_SENSORS
      return world.castRay(ray, reach, true, flags) !== null
    },
    [world, rapier],
  )
  return <RouteDriver {...props} move={move} blocked={blocked} />
}

person/build.ts

// Turn a FigureSpec into mesh data: each part meshed, skin-weighted, and painted (base color,
// baked occlusion, skin tint). A generator, so the scheduler can spread it over frames. Output is
// plain typed arrays, shareable between every figure with the same spec.
import { clamp01, type Field, lattice, len, noise3, type Prim, surfaceNets, type V3 } from './sdf'
import { BONES, type PartDef, type Shape, shapeFigure } from './shape'
import type { FigureSpec, MaterialKind } from './spec'

export type Lod = 'near' | 'far'

export interface BuiltPart {
  name: string
  material: MaterialKind
  positions: Float32Array
  normals: Float32Array
  indices: Uint32Array
  /** Linear RGB, final color (base x occlusion x tint). */
  colors: Float32Array
  skinIndex: Uint16Array
  skinWeight: Float32Array
}

export interface BuiltFigure {
  lod: Lod
  joints: V3[]
  height: number
  eyes: Shape['eyes']
  lidRadius: number
  marks: Shape['marks']
  props: Shape['props']
  parts: BuiltPart[]
  triangles: number
}

/** Reference height the part cell sizes are tuned for. */
const REF_HEIGHT = 1.65
const VERTS_PER_SLICE = 512

export function* buildFigure(spec: FigureSpec, lod: Lod): Generator<void, BuiltFigure> {
  const shape = shapeFigure(spec)
  const scale = shape.height / REF_HEIGHT
  const parts: BuiltPart[] = []
  let triangles = 0
  // Occlusion reads a coarse cached copy of the whole figure's field, shared by every part.
  const occluder = lattice(shape.occluder, (lod === 'near' ? 0.012 : 0.03) * scale)
  for (const def of shape.parts) {
    if (lod === 'near' ? def.farOnly : def.nearOnly || def.far === null) continue
    const h = (lod === 'near' ? def.near : (def.far ?? def.near)) * scale
    const raw = yield* surfaceNets(def.field, def.min, def.max, h)
    if (raw.indices.length === 0) continue
    const { skinIndex, skinWeight } = yield* weigh(
      raw.positions,
      def.weightBy,
      def.sigma,
      def.below,
    )
    const colors = yield* paint(raw.positions, raw.normals, def, shape, spec, scale, occluder)
    parts.push({ name: def.name, material: def.material, ...raw, colors, skinIndex, skinWeight })
    triangles += raw.indices.length / 3
  }
  return {
    lod,
    joints: shape.joints,
    height: shape.height,
    eyes: shape.eyes,
    lidRadius: shape.lidRadius,
    marks: shape.marks,
    props: shape.props,
    parts,
    triangles,
  }
}

/** Skin weights from distance to each bone's primitives: soft-min falloff, best four kept. */
export function* weigh(
  positions: Float32Array,
  prims: readonly Prim[],
  sigma: number,
  below?: { y: number; maxX: number; prims: readonly Prim[]; sigma: number },
) {
  const count = positions.length / 3
  const skinIndex = new Uint16Array(count * 4)
  const skinWeight = new Float32Array(count * 4)
  const dist = new Float64Array(BONES.length)
  const above = prims.filter((q) => !q.sub)
  const under = below ? below.prims.filter((q) => !q.sub) : above
  const top: number[] = []
  for (let v = 0; v < count; v++) {
    if (v % VERTS_PER_SLICE === 0) yield
    const x = positions[v * 3]
    const y = positions[v * 3 + 1]
    const z = positions[v * 3 + 2]
    dist.fill(1e9)
    const low = below !== undefined && y < below.y && Math.abs(x) < below.maxX
    const adds = low ? under : above
    const fall = low && below ? below.sigma : sigma
    for (const q of adds) {
      if (len(x - q.c[0], y - q.c[1], z - q.c[2]) - q.R > dist[q.bone]) continue
      const d = q.d(x, y, z)
      if (d < dist[q.bone]) dist[q.bone] = d
    }
    let dmin = 1e9
    for (let b = 0; b < dist.length; b++) dmin = Math.min(dmin, dist[b])
    top.length = 0
    for (let b = 0; b < dist.length; b++) {
      if (dist[b] > 1e8) continue
      const w = Math.exp(-(dist[b] - dmin) / fall)
      if (w > 0.01) top.push(b, w)
    }
    // Keep the four heaviest (pairs of [bone, weight]); a vertex rarely has more than three.
    const order = Array.from({ length: top.length / 2 }, (_, i) => i).sort(
      (a, b) => top[b * 2 + 1] - top[a * 2 + 1],
    )
    let sum = 0
    for (let i = 0; i < Math.min(4, order.length); i++) sum += top[order[i] * 2 + 1]
    for (let i = 0; i < Math.min(4, order.length); i++) {
      skinIndex[v * 4 + i] = top[order[i] * 2]
      skinWeight[v * 4 + i] = top[order[i] * 2 + 1] / sum
    }
    if (order.length === 0) skinWeight[v * 4] = 1
  }
  return { skinIndex, skinWeight }
}

const linearCache = new Map<string, V3>()
/** sRGB hex to linear RGB (vertex colors are linear). */
export function linear(hex: string): V3 {
  let c = linearCache.get(hex)
  if (!c) {
    const v = Number.parseInt(hex.replace('#', '').padEnd(6, '0').slice(0, 6), 16)
    const ch = (s: number) => {
      const x = ((v >> s) & 255) / 255
      return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
    }
    c = [ch(16), ch(8), ch(0)]
    linearCache.set(hex, c)
  }
  return c
}

/** iq-style field occlusion: how much closer the field gets than a ray along the normal. */
function occlusion(
  f: Field,
  x: number,
  y: number,
  z: number,
  nx: number,
  ny: number,
  nz: number,
  s: number,
) {
  let occ = 0
  let w = 1
  for (let i = 1; i <= 3; i++) {
    const h = 0.006 * s * i * i
    occ += (h - Math.max(f(x + nx * h, y + ny * h, z + nz * h), 0)) * w
    w *= 0.55
  }
  return Math.max(0.35, Math.min(1, 1 - (occ * 18) / s))
}

function* paint(
  positions: Float32Array,
  normals: Float32Array,
  def: PartDef,
  shape: Shape,
  spec: FigureSpec,
  s: number,
  occluder: Field,
) {
  const out = new Float32Array(positions.length)
  const n = noise3(spec.seed + 7)
  const isSkin = def.material === 'skin'
  const lip = linear(spec.colors.lip)
  const hair = linear(spec.colors.hair)
  const flat = typeof def.paint === 'string' ? linear(def.paint) : null
  for (let i = 0; i < positions.length; i += 3) {
    if ((i / 3) % VERTS_PER_SLICE === 0) yield
    const x = positions[i]
    const y = positions[i + 1]
    const z = positions[i + 2]
    const ao = occlusion(occluder, x, y, z, normals[i], normals[i + 1], normals[i + 2], s)
    let base = flat
    if (!base) {
      const hex = (def.paint as (x: number, y: number, z: number) => string | null)(x, y, z)
      base = linear(hex ?? '#808080')
    }
    let r = base[0]
    let g = base[1]
    let b = base[2]
    const grain =
      1 +
      (n((x * 40) / s, (y * 40) / s, (z * 40) / s) - 0.5) *
        (isSkin ? 0.05 : def.material === 'hair' ? 0.22 : 0.08)
    if (isSkin) {
      const t = shape.skinPaint(x, y, z)
      r = r * (1 + t.flush * 0.06)
      g = g * (1 - t.flush * 0.14)
      b = b * (1 - t.flush * 0.12)
      const lw = clamp01(t.lip * 0.85)
      r += (lip[0] - r) * lw
      g += (lip[1] - g) * lw
      b += (lip[2] - b) * lw
      const sw = t.stubble * 0.35
      r += (hair[0] - r) * sw
      g += (hair[1] - g) * sw
      b += (hair[2] - b) * sw
    }
    out[i] = r * ao * grain
    out[i + 1] = g * ao * grain
    out[i + 2] = b * ao * grain
  }
  return out
}

person/materials.ts

// Materials shared by every figure. Colors are baked into vertex colors, so one material per
// surface kind serves the whole crowd (and one shader program each).
import {
  BufferAttribute,
  Color,
  MeshPhysicalMaterial,
  type MeshPhysicalMaterialParameters,
  ShaderChunk,
  SphereGeometry,
} from 'three'
import { linear } from './build'
import type { MaterialKind } from './spec'

const SKIN_DIFFUSE =
  'reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseContribution );'

/**
 * Skin: sheen for the fine fuzz, and a red-shifted wrap on the direct diffuse term as a cheap
 * stand-in for subsurface scattering (light bleeds past the terminator, warmer as it goes).
 * If a three.js release renames the patched line, the skin falls back to plain physical shading.
 */
function skin() {
  const m = new MeshPhysicalMaterial({
    vertexColors: true,
    roughness: 0.55,
    specularIntensity: 0.4,
    sheen: 0.35,
    sheenColor: new Color('#ff9a7a'),
    sheenRoughness: 0.55,
  })
  m.customProgramCacheKey = () => 'runek-person-skin'
  m.onBeforeCompile = (shader) => {
    const chunk = ShaderChunk.lights_physical_pars_fragment
    if (!chunk.includes(SKIN_DIFFUSE)) return
    shader.fragmentShader = shader.fragmentShader.replace(
      '#include <lights_physical_pars_fragment>',
      chunk.replace(
        SKIN_DIFFUSE,
        `vec3 wrapW = vec3( 0.45, 0.2, 0.12 );
         vec3 wrapNL = clamp( ( vec3( dot( geometryNormal, directLight.direction ) ) + wrapW ) / ( 1.0 + wrapW ), 0.0, 1.0 );
         reflectedLight.directDiffuse += wrapNL * directLight.color * BRDF_Lambert( material.diffuseContribution );`,
      ),
    )
  }
  return m
}

const SPECS: Record<Exclude<MaterialKind, 'skin'>, MeshPhysicalMaterialParameters> = {
  cloth: { roughness: 0.88, sheen: 0.6, sheenColor: new Color('#ffffff'), sheenRoughness: 0.8 },
  knit: { roughness: 0.95, sheen: 0.9, sheenColor: new Color('#ffffff'), sheenRoughness: 0.6 },
  denim: { roughness: 0.82, sheen: 0.45, sheenColor: new Color('#b8c6d6'), sheenRoughness: 0.7 },
  leather: { roughness: 0.42, clearcoat: 0.35, clearcoatRoughness: 0.35 },
  hair: { roughness: 0.5, sheen: 0.6, sheenColor: new Color('#ffffff'), sheenRoughness: 0.32 },
}

let shared: Record<MaterialKind | 'eye', MeshPhysicalMaterial> | null = null

export function personMaterials() {
  if (!shared) {
    shared = {
      skin: skin(),
      cloth: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.cloth }),
      knit: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.knit }),
      denim: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.denim }),
      leather: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.leather }),
      hair: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.hair }),
      eye: new MeshPhysicalMaterial({
        vertexColors: true,
        roughness: 0.25,
        clearcoat: 1,
        clearcoatRoughness: 0.03,
      }),
    }
  }
  return shared
}

/** An eyeball looking down +z: pupil, a streaked iris, a dark limbal ring, warm sclera. */
export function eyeGeometry(radius: number, iris: string, detail: number) {
  const g = new SphereGeometry(radius, detail, Math.round(detail * 0.66))
  g.rotateX(Math.PI / 2)
  const p = g.attributes.position
  const col = new Float32Array(p.count * 3)
  const ir = linear(iris)
  const sclera = linear('#efe9e2')
  const vein = linear('#e3c0b8')
  for (let i = 0; i < p.count; i++) {
    const ang = Math.acos(Math.max(-1, Math.min(1, p.getZ(i) / radius)))
    const around = Math.atan2(p.getY(i), p.getX(i))
    let c: [number, number, number]
    if (ang < 0.2) c = [0.002, 0.002, 0.002]
    else if (ang < 0.56) {
      const k =
        (0.75 + 0.25 * Math.sin(around * 37) * Math.sin(around * 11 + 1)) *
        (0.65 + (ang - 0.2) * 1.1)
      c = [ir[0] * k, ir[1] * k, ir[2] * k]
    } else if (ang < 0.62) c = [0.01, 0.008, 0.007]
    else {
      const t = Math.min(1, (ang - 0.62) / 1.2) * 0.6
      c = [
        sclera[0] + (vein[0] - sclera[0]) * t,
        sclera[1] + (vein[1] - sclera[1]) * t,
        sclera[2] + (vein[2] - sclera[2]) * t,
      ]
    }
    col.set(c, i * 3)
  }
  g.setAttribute('color', new BufferAttribute(col, 3))
  return g
}

/** The upper lid for blinking: a hemispherical cap just outside the eyeball, in skin. */
export function lidGeometry(radius: number, skinHex: string) {
  const g = new SphereGeometry(radius, 28, 10, 0, Math.PI * 2, 0, Math.PI / 2)
  const c = linear(skinHex)
  const col = new Float32Array(g.attributes.position.count * 3)
  for (let i = 0; i < col.length; i += 3) col.set([c[0] * 0.85, c[1] * 0.8, c[2] * 0.8], i)
  g.setAttribute('color', new BufferAttribute(col, 3))
  return g
}

person/resolve.ts

// What a Person's props resolve to, trait by trait, and where each value came from. Built on the
// same `resolveFigure` the component renders with, so the answer always matches the figure.
import { DEFAULT_PALETTE, type WorldPalette } from '@runek/core'
import { KINDS, PERSON_SKINS, resolveFigure, type TraitInput } from './spec'
import type {
  PersonAccessory,
  PersonAge,
  PersonBuild,
  PersonFacialHair,
  PersonGarment,
  PersonGender,
  PersonHair,
  PersonHat,
  PersonKind,
  PersonNose,
  PersonOutfit,
  PersonSkin,
  PersonStyle,
} from './types'

/** Where a resolved trait came from: a prop (flat or part object), the `skin` bundle, the `kind`
 *  preset, the seeded roll, or the component's fixed default. */
export type PersonTraitSource = 'prop' | 'skin' | 'kind' | 'seed' | 'default'

export interface PersonTrait<T> {
  value: T
  from: PersonTraitSource
}

/** A clothing layer with every option settled. `tucked` comes from the stack order. */
export interface ResolvedGarment extends Required<PersonGarment> {
  tucked: boolean
}

export interface ResolvedPerson {
  style: PersonTrait<PersonStyle>
  kind: PersonTrait<PersonKind>
  gender: PersonTrait<PersonGender>
  age: PersonTrait<PersonAge>
  build: PersonTrait<PersonBuild>
  /** Standing height, in units. */
  height: PersonTrait<number>
  tone: PersonTrait<string>
  hair: PersonTrait<PersonHair>
  hairColor: PersonTrait<string>
  facialHair: PersonTrait<PersonFacialHair>
  eyeColor: PersonTrait<string>
  eyeSize: PersonTrait<number>
  browColor: PersonTrait<string>
  browWeight: PersonTrait<number>
  nose: PersonTrait<PersonNose>
  lipColor: PersonTrait<string>
  lipFullness: PersonTrait<number>
  blush: PersonTrait<number>
  /** The one-word outfit the layers expanded from; `null` when `clothes` were given outright. */
  outfit: PersonTrait<PersonOutfit | null>
  clothes: PersonTrait<ResolvedGarment[]>
  topColor: PersonTrait<string>
  bottomColor: PersonTrait<string>
  shoeColor: PersonTrait<string>
  hat: PersonTrait<PersonHat>
  accessories: PersonTrait<PersonAccessory[]>
}

const trait = <T>(value: T, from: PersonTraitSource): PersonTrait<T> => ({ value, from })

/**
 * Resolve a `Person`'s props the way the component does and report every trait with its source,
 * so an editor (or an agent) can tell what the seed chose: `resolvePerson({ seed: 3 }).hair` is
 * `{ value: 'braid', from: 'seed' }`. Pass the world's palette and unit to match a figure in a
 * world that sets them. Pure and cheap: no geometry is built.
 */
export function resolvePerson(
  props: Partial<TraitInput>,
  palette: WorldPalette = DEFAULT_PALETTE,
  unit = 1,
): ResolvedPerson {
  const t: TraitInput = {
    ...props,
    seed: props.seed ?? 1,
    style: props.style ?? 'stylized',
    kind: props.kind ?? 'villager',
    age: props.age ?? 'adult',
  }
  const { spec, slots } = resolveFigure(t, palette, unit)
  const sk: PersonSkin | undefined = typeof t.skin === 'string' ? PERSON_SKINS[t.skin] : t.skin
  const preset = KINDS[t.kind]
  const hairObj = typeof t.hair === 'object' ? t.hair : undefined
  const hairName = typeof t.hair === 'string' ? t.hair : undefined
  const f = t.face ?? {}
  const given = (v: unknown) => v !== undefined
  const chain = (prop: unknown, skin: unknown, fallback: PersonTraitSource): PersonTraitSource =>
    given(prop) ? 'prop' : given(skin) ? 'skin' : fallback

  const explicit = t.clothes !== undefined ? 'prop' : sk?.clothes !== undefined ? 'skin' : null
  const outfitFrom = chain(t.outfit, sk?.outfit, 'kind')

  return {
    style: trait(t.style, given(props.style) ? 'prop' : 'default'),
    kind: trait(t.kind, given(props.kind) ? 'prop' : 'default'),
    gender: trait(spec.gender, given(t.gender) ? 'prop' : 'seed'),
    age: trait(t.age, given(props.age) ? 'prop' : 'default'),
    build: trait(spec.build, given(t.body?.build ?? t.build) ? 'prop' : 'seed'),
    height: trait(spec.height / unit, given(t.body?.height ?? t.height) ? 'prop' : 'default'),
    tone: trait(spec.colors.skin, given(t.body?.tone ?? t.skinTone) ? 'prop' : 'seed'),
    hair: trait(spec.hair, chain(hairObj?.style ?? hairName, sk?.hair, 'seed')),
    hairColor: trait(spec.colors.hair, chain(hairObj?.color ?? t.hairColor, sk?.hairColor, 'seed')),
    facialHair: trait(spec.facialHair, given(t.facialHair) ? 'prop' : 'seed'),
    eyeColor: trait(spec.colors.eye, given(f.eyes?.color) ? 'prop' : 'seed'),
    eyeSize: trait(f.eyes?.size ?? 1, given(f.eyes?.size) ? 'prop' : 'default'),
    browColor: trait(spec.colors.brow, given(f.brows?.color) ? 'prop' : 'default'),
    browWeight: trait(spec.face.browWeight, given(f.brows?.weight) ? 'prop' : 'default'),
    nose: trait(
      spec.face.noseKind,
      given(f.nose) ? 'prop' : t.style === 'realistic' ? 'seed' : 'default',
    ),
    lipColor: trait(spec.colors.lip, given(f.lips?.color) ? 'prop' : 'default'),
    lipFullness: trait(f.lips?.fullness ?? 1, given(f.lips?.fullness) ? 'prop' : 'default'),
    blush: trait(spec.face.blush, given(f.blush) ? 'prop' : 'default'),
    outfit: explicit
      ? trait(null, explicit)
      : trait(t.outfit ?? sk?.outfit ?? preset.outfit, outfitFrom),
    clothes: trait(
      spec.layers.map((l) => ({
        type: l.type,
        color: l.color,
        sleeves: l.sleeves,
        neck: l.neck,
        length: l.length,
        tucked: l.tucked,
      })),
      explicit ?? outfitFrom,
    ),
    topColor: trait(slots.top, chain(t.topColor, sk?.topColor, 'seed')),
    bottomColor: trait(slots.bottom, chain(t.bottomColor, sk?.bottomColor, 'seed')),
    shoeColor: trait(slots.shoe, chain(t.shoeColor, sk?.shoeColor, 'default')),
    hat: trait(spec.hat, chain(t.hat, sk?.hat, 'kind')),
    accessories: trait(
      t.accessories ?? sk?.accessories ?? preset.accessories,
      chain(t.accessories, sk?.accessories, 'kind'),
    ),
  }
}

person/rig.ts

// The three.js side of a figure: a skeleton from the spec's joints, skinned meshes bound to it, and
// the rigid pieces that ride a bone (eyeballs, blink lids, glasses, the staff).
import {
  Bone,
  BufferAttribute,
  BufferGeometry,
  CylinderGeometry,
  Group,
  Matrix4,
  Mesh,
  MeshPhysicalMaterial,
  Object3D,
  Skeleton,
  SkinnedMesh,
  Sphere,
  TorusGeometry,
  Vector3,
} from 'three'
import type { BuiltFigure } from './build'
import { eyeGeometry, lidGeometry, personMaterials } from './materials'
import type { V3 } from './sdf'
import { BONES, type BoneName, PARENT, type Shape } from './shape'

export interface Rig {
  group: Group
  bones: Record<BoneName, Bone>
  skeleton: Skeleton
}

export function createRig(joints: V3[]): Rig {
  const group = new Group()
  const list = BONES.map((name) => {
    const b = new Bone()
    b.name = name
    return b
  })
  list.forEach((b, i) => {
    const j = joints[i]
    const p = PARENT[i] >= 0 ? joints[PARENT[i]] : [0, 0, 0]
    b.position.set(j[0] - p[0], j[1] - p[1], j[2] - p[2])
    if (PARENT[i] >= 0) list[PARENT[i]].add(b)
  })
  group.add(list[0])
  group.updateMatrixWorld(true)
  const skeleton = new Skeleton(list)
  const bones = Object.fromEntries(BONES.map((n, i) => [n, list[i]])) as Record<BoneName, Bone>
  return { group, bones, skeleton }
}

/** Skinned meshes for one build, bound to the rig's skeleton (the caller adds them to `rig.group`). */
export function skinnedMeshes(built: BuiltFigure, rig: Rig) {
  const materials = personMaterials()
  const bounds = new Sphere(new Vector3(0, built.height * 0.5, 0), built.height * 0.85)
  const meshes = built.parts.map((part) => {
    const g = new BufferGeometry()
    g.setAttribute('position', new BufferAttribute(part.positions, 3))
    g.setAttribute('normal', new BufferAttribute(part.normals, 3))
    g.setAttribute('color', new BufferAttribute(part.colors, 3))
    g.setAttribute('skinIndex', new BufferAttribute(part.skinIndex, 4))
    g.setAttribute('skinWeight', new BufferAttribute(part.skinWeight, 4))
    g.setIndex(new BufferAttribute(part.indices, 1))
    g.boundingSphere = bounds.clone()
    const mesh = new SkinnedMesh(g, materials[part.material])
    mesh.name = part.name
    mesh.castShadow = true
    mesh.receiveShadow = true
    mesh.boundingSphere = bounds.clone()
    mesh.bind(rig.skeleton, new Matrix4())
    return mesh
  })
  return {
    meshes,
    dispose: () => {
      for (const m of meshes) {
        m.removeFromParent()
        m.geometry.dispose()
      }
    },
  }
}

export interface Attachments {
  eyes: Object3D[]
  lids: Object3D[]
  /** Glasses and staff. */
  props: Object3D[]
  dispose: () => void
}

/** Eyeballs and blink lids on the head bone, glasses on the face, the staff in the right hand. */
export function attach(
  rig: Rig,
  shape: Shape,
  joints: V3[],
  eyeColor: string,
  skinColor: string,
  metal: string,
  wood: string,
): Attachments {
  const head = rig.bones.head
  const hj = joints[BONES.indexOf('head')]
  const local = (p: V3): V3 => [p[0] - hj[0], p[1] - hj[1], p[2] - hj[2]]
  const owned: { obj: Object3D; geo: BufferGeometry[]; mat?: MeshPhysicalMaterial }[] = []
  const props: Object3D[] = []
  const materials = personMaterials()
  const eyes: Object3D[] = []
  const lids: Object3D[] = []
  for (const e of shape.eyes) {
    const pivot = new Object3D()
    pivot.position.set(...local(e.center))
    const eyeGeo = eyeGeometry(e.radius, eyeColor, 40)
    pivot.add(new Mesh(eyeGeo, materials.eye))
    head.add(pivot)
    eyes.push(pivot)
    const lidPivot = new Object3D()
    lidPivot.position.set(...local(e.center))
    const lidGeo = lidGeometry(shape.lidRadius, skinColor)
    lidPivot.add(new Mesh(lidGeo, materials.skin))
    head.add(lidPivot)
    lids.push(lidPivot)
    owned.push({ obj: pivot, geo: [eyeGeo] }, { obj: lidPivot, geo: [lidGeo] })
  }
  const g = shape.props.glasses
  if (g) {
    const frame = new Group()
    frame.position.set(...local(g.center))
    const mat = new MeshPhysicalMaterial({ color: metal, roughness: 0.4, metalness: 0.6 })
    const ring = new TorusGeometry(g.ring, g.tube, 6, 24)
    const bridge = new CylinderGeometry(g.tube, g.tube, g.spacing * 2 - g.ring * 2, 6)
    for (const s of [-1, 1]) {
      const m = new Mesh(ring, mat)
      m.position.x = s * g.spacing
      frame.add(m)
    }
    const b = new Mesh(bridge, mat)
    b.rotation.z = Math.PI / 2
    b.position.y = g.ring * 0.35
    frame.add(b)
    head.add(frame)
    props.push(frame)
    owned.push({ obj: frame, geo: [ring, bridge], mat })
  }
  const st = shape.props.staff
  if (st) {
    const hand = rig.bones['hand.R']
    const wrist = joints[BONES.indexOf('hand.R')]
    const geo = new CylinderGeometry(st.radius, st.radius, st.length, 7)
    const mat = new MeshPhysicalMaterial({ color: wood, roughness: 0.85 })
    const m = new Mesh(geo, mat)
    // Held through the fist, its foot on the ground.
    m.position.set(-st.radius * 0.5, st.length / 2 - wrist[1], st.radius * 2.6)
    m.castShadow = true
    hand.add(m)
    props.push(m)
    owned.push({ obj: m, geo: [geo], mat })
  }
  return {
    eyes,
    lids,
    props,
    dispose: () => {
      for (const o of owned) {
        o.obj.removeFromParent()
        for (const geo of o.geo) geo.dispose()
        o.mat?.dispose()
      }
    },
  }
}

person/route.ts

// A walking route as a pure function of time: where a figure is, which way it faces, and how
// fast it moves at any moment, with no integrated state. Two viewers sampling the same route at
// the same clock see the same thing. Plain TS (no three.js, no React) so it tests in isolation.
import { range, rng, type Vec3 } from '@runek/core'

/** `loop` closes the route (last point walks back to the first); `pingpong` retraces it. */
export type RouteLoop = 'loop' | 'pingpong'

export interface RouteOptions {
  /** Walking speed, in units per second. */
  speed: number
  /** Seconds held at each waypoint before setting off again. */
  pause: number
  /** How the route repeats, or `once` for a single trip that sets off at `t = 0` and holds the
   *  last point from `t = period` on. */
  loop: RouteLoop | 'once'
  /** Seconds to reach full speed from a standstill (and to stop again). */
  accel?: number
}

export interface RouteSample {
  x: number
  y: number
  z: number
  /** Yaw of travel (0 faces +z). While paused, the heading the figure arrived with. */
  heading: number
  /** Current speed along the route, in units per second. */
  speed: number
  /** Distance walked since the start of the cycle, for a walk cycle that doesn't slide. */
  distance: number
}

export interface Route {
  /** Seconds for one full cycle, pauses included; for a trip, the arrival time. Zero for a route
   *  that never moves. */
  period: number
  /** A single trip rather than a repeating cycle. */
  once: boolean
  /** Where the figure is at `t` seconds (any real number; a cycle repeats every `period`, a trip
   *  waits at its first point before 0 and holds its last after `period`). */
  sample: (t: number) => RouteSample
}

interface Leg {
  from: Vec3
  to: Vec3
  length: number
  heading: number
  /** Seconds held at the leg's start, then seconds spent moving. */
  wait: number
  move: number
  /** Ramp time at each end: the full `accel`, or half the move for a leg too short to cruise. */
  ramp: number
  /** Cycle time the leg's pause starts, and distance walked before it. */
  start: number
  before: number
}

const DEFAULT_ACCEL = 0.4

/**
 * Build a route through node-local waypoints. The legs join them in order (closing back to the
 * first for `loop`, retracing for `pingpong`, stopping at the last for `once`); each leg pauses,
 * then eases up to `speed`, cruises, and eases to a stop. A trip sets off without the first
 * pause. Fewer than two distinct points stand still at the first.
 */
export function buildRoute(points: Vec3[], options: RouteOptions): Route {
  const { speed, pause, loop, accel = DEFAULT_ACCEL } = options
  const once = loop === 'once'
  const order = loop === 'pingpong' ? [...points, ...points.slice(1, -1).reverse()] : points
  const count = once ? order.length - 1 : order.length
  const legs: Leg[] = []
  let clock = 0
  let walked = 0
  for (let i = 0; i < count && order.length > 1; i++) {
    const from = order[i]
    const to = order[(i + 1) % order.length]
    const wait = once && i === 0 ? 0 : pause
    const length = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
    const ramp = speed > 0 ? Math.min(accel, length / speed) : 0
    const move = speed > 0 && length > 0 ? length / speed + ramp : 0
    legs.push({
      from,
      to,
      length,
      heading: Math.atan2(to[0] - from[0], to[2] - from[2]),
      wait,
      move,
      ramp,
      start: clock,
      before: walked,
    })
    clock += wait + move
    walked += length
  }

  const first = points[0] ?? [0, 0, 0]
  if (!legs.some((leg) => leg.move > 0)) {
    const still: RouteSample = {
      x: first[0],
      y: first[1],
      z: first[2],
      heading: 0,
      speed: 0,
      distance: 0,
    }
    return { period: 0, once, sample: () => still }
  }

  // The heading a figure arrives at each leg's start with: the last leg before it that moves. A
  // trip hasn't arrived anywhere before it sets off, so it faces the node's own way.
  const arrival = legs.map((_, i) => {
    for (let k = 1; k <= (once ? i : legs.length); k++) {
      const prev = legs[(i - k + legs.length) % legs.length]
      if (prev.length > 0) return prev.heading
    }
    return 0
  })

  const period = clock
  const last = order[order.length - 1]
  const end: RouteSample = {
    x: last[0],
    y: last[1],
    z: last[2],
    heading: legs.findLast((leg) => leg.length > 0)?.heading ?? 0,
    speed: 0,
    distance: walked,
  }
  const sample = (t: number): RouteSample => {
    if (once && t >= period) return end
    const at = once ? t : ((t % period) + period) % period
    let i = legs.length - 1
    while (i > 0 && legs[i].start > at) i--
    const leg = legs[i]
    const tau = at - leg.start - leg.wait
    if (tau <= 0 || leg.move === 0) {
      return {
        x: leg.from[0],
        y: leg.from[1],
        z: leg.from[2],
        heading: arrival[i],
        speed: 0,
        distance: leg.before,
      }
    }
    // Trapezoidal speed: ramp up, cruise, ramp down, so starts and stops never pop.
    const { ramp, move, length } = leg
    let s: number
    let v: number
    if (tau < ramp) {
      s = (speed * tau * tau) / (2 * ramp)
      v = (speed * tau) / ramp
    } else if (tau < move - ramp) {
      s = (speed * ramp) / 2 + speed * (tau - ramp)
      v = speed
    } else {
      const left = Math.max(move - tau, 0)
      s = length - (speed * left * left) / (2 * ramp)
      v = (speed * left) / ramp
    }
    const u = Math.min(Math.max(s / length, 0), 1)
    return {
      x: leg.from[0] + (leg.to[0] - leg.from[0]) * u,
      y: leg.from[1] + (leg.to[1] - leg.from[1]) * u,
      z: leg.from[2] + (leg.to[2] - leg.from[2]) * u,
      heading: leg.heading,
      speed: v,
      distance: leg.before + s,
    }
  }
  return { period, once, sample }
}

/**
 * Where a `route` trip has got to at `now` (epoch ms), in the node's local frame and ignoring
 * detours: for an app starting a new trip from wherever the last one had reached. `speed` and
 * `pause` are the figure's own (an adult walks 1.3 m/s; the default pause is 1.5 s).
 */
export function tripAt(
  points: Vec3[],
  departAt: number,
  now: number,
  { speed = 1.3, pause = 1.5 }: { speed?: number; pause?: number } = {},
): Vec3 {
  const p = buildRoute(points, { speed, pause, loop: 'once' }).sample((now - departAt) / 1000)
  return [p.x, p.y, p.z]
}

/**
 * A seeded wander loop inside `radius`: the origin, then `count - 1` points around it sorted by
 * angle, so the loop never crosses itself. Same seed, same loop.
 */
export function wanderPoints(radius: number, seed: number, count = 5): Vec3[] {
  const r = rng(seed)
  const n = Math.max(count - 1, 2)
  const offset = r() * Math.PI * 2
  const points: Vec3[] = [[0, 0, 0]]
  for (let i = 0; i < n; i++) {
    const angle = offset + ((i + range(r, 0.15, 0.85)) / n) * Math.PI * 2
    const reach = radius * range(r, 0.4, 1)
    points.push([Math.sin(angle) * reach, 0, Math.cos(angle) * reach])
  }
  return points
}

person/scheduler.ts

// One shared queue for figure builds: short tasks between frames (never more than a few
// milliseconds at once, so input and rendering stay smooth), coarse builds before detailed ones,
// nearest first. Finished builds are cached by spec, so identical figures
// (and a figure that walks out of detail range and back) never build twice.
import { type BuiltFigure, buildFigure, type Lod } from './build'
import type { FigureSpec } from './spec'

/** Longest stretch of build work before yielding back to the page, in milliseconds. */
const BUDGET = 6
/** Finished builds kept for reuse (a full-detail one is ~10 MB of arrays). */
const KEEP: Record<Lod, number> = { near: 4, far: 48 }

type Listener = (built: BuiltFigure) => void

interface Job {
  key: string
  lod: Lod
  gen: Generator<void, BuiltFigure>
  listeners: Map<Listener, () => number>
}

const cache: Record<Lod, Map<string, BuiltFigure>> = { near: new Map(), far: new Map() }
const jobs = new Map<string, Job>()
let ticking = false

export function figureKey(spec: FigureSpec) {
  return JSON.stringify(spec)
}

/** A finished build, if there is one (refreshing its place in the cache). */
export function peekFigure(key: string, lod: Lod): BuiltFigure | null {
  const hit = cache[lod].get(key)
  if (!hit) return null
  cache[lod].delete(key)
  cache[lod].set(key, hit)
  return hit
}

/**
 * Ask for a build. `onReady` fires once it's done (synchronously when cached); `distance` is read
 * whenever the queue picks its next job. Returns a cancel: a build nobody waits for is dropped.
 */
export function requestFigure(
  spec: FigureSpec,
  key: string,
  lod: Lod,
  distance: () => number,
  onReady: Listener,
): () => void {
  const hit = peekFigure(key, lod)
  if (hit) {
    onReady(hit)
    return () => {}
  }
  const id = `${lod}:${key}`
  let job = jobs.get(id)
  if (!job) {
    job = { key, lod, gen: buildFigure(spec, lod), listeners: new Map() }
    jobs.set(id, job)
  }
  job.listeners.set(onReady, distance)
  schedule()
  const mine = job
  return () => {
    mine.listeners.delete(onReady)
    if (mine.listeners.size === 0 && jobs.get(id) === mine) jobs.delete(id)
  }
}

function priority(job: Job) {
  let d = Number.POSITIVE_INFINITY
  for (const read of job.listeners.values()) d = Math.min(d, read())
  return (job.lod === 'far' ? 0 : 1e6) + d
}

// A message-channel task runs as soon as the page is idle, without setTimeout's clamping.
let channel: MessageChannel | null = null
function schedule() {
  if (ticking || jobs.size === 0) return
  ticking = true
  if (typeof MessageChannel === 'function') {
    if (!channel) {
      channel = new MessageChannel()
      channel.port1.onmessage = tick
    }
    channel.port2.postMessage(null)
  } else setTimeout(tick, 0)
}

function tick() {
  ticking = false
  const deadline = performance.now() + BUDGET
  while (jobs.size > 0 && performance.now() < deadline) {
    let best: [string, Job] | null = null
    let bestP = Number.POSITIVE_INFINITY
    for (const entry of jobs) {
      const p = priority(entry[1])
      if (p < bestP) {
        bestP = p
        best = entry
      }
    }
    if (!best) break
    const [id, job] = best
    let done: BuiltFigure | null = null
    while (performance.now() < deadline) {
      const r = job.gen.next()
      if (r.done) {
        done = r.value
        break
      }
    }
    if (!done) break
    jobs.delete(id)
    const store = cache[job.lod]
    store.set(job.key, done)
    while (store.size > KEEP[job.lod]) {
      const oldest = store.keys().next().value
      if (oldest === undefined) break
      store.delete(oldest)
    }
    for (const listener of job.listeners.keys()) listener(done)
  }
  schedule()
}

person/sdf.ts

// Signed-distance building blocks for the figure: primitives, an ordered smooth "program" over
// tagged primitives, and a narrow-band surface-nets mesher written as a generator so a build can
// be spread over many frames. Plain TS (no three.js, no React), so it tests in isolation.

export type V3 = [number, number, number]
export type Field = (x: number, y: number, z: number) => number

export const len = (a: number, b: number, c = 0) => Math.sqrt(a * a + b * b + c * c)
export const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)

/** Polynomial smooth minimum: `k` is the blend radius (0 is a hard union). */
export function smin(a: number, b: number, k: number) {
  if (k <= 0) return a < b ? a : b
  const h = Math.max(k - Math.abs(a - b), 0) / k
  return Math.min(a, b) - h * h * k * 0.25
}
export const smax = (a: number, b: number, k: number) => -smin(-a, -b, k)

/** One tagged piece of a figure: its field, a bounding sphere, and how it blends in. */
export interface Prim {
  d: Field
  c: V3
  R: number
  /** Blend radius into everything before it. */
  k: number
  /** Carves instead of adds. */
  sub?: boolean
  /** Index of the bone it rides, which is where skin weights come from. */
  bone: number
  tag?: string
}

/** Rotation as an inverse 3x3 (row-major), from XYZ Euler angles. */
function inverseRotation(rot?: V3) {
  if (!rot) return null
  const [ax, ay, az] = rot
  const cx = Math.cos(ax)
  const sx = Math.sin(ax)
  const cy = Math.cos(ay)
  const sy = Math.sin(ay)
  const cz = Math.cos(az)
  const sz = Math.sin(az)
  // R = Rz Ry Rx; its inverse is the transpose.
  return [
    cz * cy,
    sz * cy,
    -sy,
    cz * sy * sx - sz * cx,
    sz * sy * sx + cz * cx,
    cy * sx,
    cz * sy * cx + sz * sx,
    sz * sy * cx - cz * sx,
    cy * cx,
  ]
}

/** iq's ellipsoid bound: exact on the surface, a good estimate near it. */
export function ellipsoid(c: V3, r: V3, rot?: V3): Field {
  const m = inverseRotation(rot)
  const [rx, ry, rz] = r
  return (x, y, z) => {
    let px = x - c[0]
    let py = y - c[1]
    let pz = z - c[2]
    if (m) {
      const qx = m[0] * px + m[1] * py + m[2] * pz
      const qy = m[3] * px + m[4] * py + m[5] * pz
      const qz = m[6] * px + m[7] * py + m[8] * pz
      px = qx
      py = qy
      pz = qz
    }
    const k0 = len(px / rx, py / ry, pz / rz)
    const k1 = len(px / (rx * rx), py / (ry * ry), pz / (rz * rz))
    return k1 === 0 ? -Math.min(rx, ry, rz) : (k0 * (k0 - 1)) / k1
  }
}

export function sphere(c: V3, r: number): Field {
  return (x, y, z) => len(x - c[0], y - c[1], z - c[2]) - r
}

/** iq's exact round cone from `a` (radius `r1`) to `b` (radius `r2`). */
export function roundCone(a: V3, b: V3, r1: number, r2: number): Field {
  const bx = b[0] - a[0]
  const by = b[1] - a[1]
  const bz = b[2] - a[2]
  const l2 = bx * bx + by * by + bz * bz
  const rr = r1 - r2
  const a2 = l2 - rr * rr
  const il2 = 1 / l2
  return (x, y, z) => {
    const px = x - a[0]
    const py = y - a[1]
    const pz = z - a[2]
    const t = px * bx + py * by + pz * bz
    const u = t - l2
    const qx = px * l2 - bx * t
    const qy = py * l2 - by * t
    const qz = pz * l2 - bz * t
    const x2 = qx * qx + qy * qy + qz * qz
    const y2 = t * t * l2
    const z2 = u * u * l2
    const k = Math.sign(rr) * rr * rr * x2
    if (Math.sign(u) * a2 * z2 > k) return Math.sqrt(x2 + z2) * il2 - r2
    if (Math.sign(t) * a2 * y2 < k) return Math.sqrt(x2 + y2) * il2 - r1
    return (Math.sqrt(x2 * a2 * il2) + t * rr) * il2 - r1
  }
}

/** Rounded box centred at `c` with half-extents `h`. */
export function box(c: V3, h: V3, round = 0): Field {
  return (x, y, z) => {
    const qx = Math.abs(x - c[0]) - h[0] + round
    const qy = Math.abs(y - c[1]) - h[1] + round
    const qz = Math.abs(z - c[2]) - h[2] + round
    return (
      len(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) +
      Math.min(Math.max(qx, qy, qz), 0) -
      round
    )
  }
}

/** A primitive from a field and its bounding sphere. */
export function prim(d: Field, c: V3, R: number, bone: number, k = 0, extra?: Partial<Prim>): Prim {
  return { d, c, R, k, bone, ...extra }
}

/**
 * Fold primitives into one field, in order: each smooth-unions into (or carves from) everything
 * before it. A primitive whose bounding sphere can't reach the running distance plus its blend is
 * skipped, which is exact for the polynomial blend and makes far pieces free.
 */
export function program(prims: readonly Prim[], inflate = 0): Field {
  const list = prims.slice()
  return (x, y, z) => {
    let d = 1e9
    for (let i = 0; i < list.length; i++) {
      const p = list[i]
      const lb = len(x - p.c[0], y - p.c[1], z - p.c[2]) - p.R - inflate
      if (p.sub) {
        if (lb >= p.k - d) continue
        d = smax(d, -(p.d(x, y, z) - inflate), p.k)
      } else {
        if (lb >= d + p.k) continue
        d = smin(d, p.d(x, y, z) - inflate, p.k)
      }
    }
    return d
  }
}

/** Only the primitives that can affect points inside the box (plus a margin). */
export function within(prims: readonly Prim[], min: V3, max: V3, margin = 0): Prim[] {
  return prims.filter((p) => {
    const dx = Math.max(min[0] - p.c[0], 0, p.c[0] - max[0])
    const dy = Math.max(min[1] - p.c[1], 0, p.c[1] - max[1])
    const dz = Math.max(min[2] - p.c[2], 0, p.c[2] - max[2])
    return len(dx, dy, dz) <= p.R + p.k + margin
  })
}

export interface Raw {
  positions: Float32Array
  normals: Float32Array
  indices: Uint32Array
}

/** Cells processed between yields, so a scheduler can stop a build at a frame boundary. */
const SLICE = 384

/**
 * Narrow-band surface nets. A coarse pass keeps only blocks near the surface; their fine cells
 * are sampled lazily. Each vertex starts at its cell centre and is projected onto the surface
 * along the gradient, so the mesh follows the true isosurface instead of the voxel staircase.
 * Yields every few hundred cells; the return value is the mesh.
 */
export function* surfaceNets(
  f: Field,
  min: V3,
  max: V3,
  h: number,
  block = 6,
): Generator<void, Raw> {
  const nx = Math.ceil((max[0] - min[0]) / h) + 1
  const ny = Math.ceil((max[1] - min[1]) / h) + 1
  const nz = Math.ceil((max[2] - min[2]) / h) + 1
  const total = nx * ny * nz
  const dense = total <= 4_000_000 ? new Float32Array(total).fill(Number.NaN) : null
  const sparse = dense ? null : new Map<number, number>()
  const val = (i: number, j: number, k: number) => {
    const key = i + nx * (j + ny * k)
    let v = dense ? dense[key] : sparse?.get(key)
    if (v === undefined || Number.isNaN(v)) {
      v = f(min[0] + i * h, min[1] + j * h, min[2] + k * h)
      if (dense) dense[key] = v
      else sparse?.set(key, v)
    }
    return v
  }

  const H = h * block
  const reach = H * 0.9 + h * 2
  const cells: number[] = []
  let work = 0
  for (let bk = 0; bk * block < nz - 1; bk++)
    for (let bj = 0; bj * block < ny - 1; bj++)
      for (let bi = 0; bi * block < nx - 1; bi++) {
        if (++work % SLICE === 0) yield
        const cx = min[0] + (bi + 0.5) * H
        const cy = min[1] + (bj + 0.5) * H
        const cz = min[2] + (bk + 0.5) * H
        if (Math.abs(f(cx, cy, cz)) > reach) continue
        for (let k = bk * block; k < Math.min((bk + 1) * block, nz - 1); k++)
          for (let j = bj * block; j < Math.min((bj + 1) * block, ny - 1); j++)
            for (let i = bi * block; i < Math.min((bi + 1) * block, nx - 1); i++)
              cells.push(i, j, k)
      }

  // Vertex: the mean of the cell's edge crossings (from cached samples), then one step along
  // the sampled gradient onto the true surface. Normals come from the faces afterwards, so
  // placing a vertex costs one extra field evaluation.
  const verts = new Map<number, number>()
  const pos: number[] = []
  const c = new Float64Array(8)
  const EDGES = [0, 1, 2, 3, 4, 5, 6, 7, 0, 2, 1, 3, 4, 6, 5, 7, 0, 4, 1, 5, 2, 6, 3, 7]
  const vertex = (i: number, j: number, k: number) => {
    const key = i + nx * (j + ny * k)
    const got = verts.get(key)
    if (got !== undefined) return got
    for (let q = 0; q < 8; q++) c[q] = val(i + (q & 1), j + ((q >> 1) & 1), k + ((q >> 2) & 1))
    let sx = 0
    let sy = 0
    let sz = 0
    let count = 0
    for (let e = 0; e < 24; e += 2) {
      const a = EDGES[e]
      const b = EDGES[e + 1]
      if (c[a] < 0 === c[b] < 0) continue
      const t = c[a] / (c[a] - c[b])
      sx += (a & 1) + ((b & 1) - (a & 1)) * t
      sy += ((a >> 1) & 1) + (((b >> 1) & 1) - ((a >> 1) & 1)) * t
      sz += ((a >> 2) & 1) + (((b >> 2) & 1) - ((a >> 2) & 1)) * t
      count++
    }
    const fx = count ? sx / count : 0.5
    const fy = count ? sy / count : 0.5
    const fz = count ? sz / count : 0.5
    // Trilinear gradient of the samples at that point.
    const gx =
      (c[1] - c[0]) * (1 - fy) * (1 - fz) +
      (c[3] - c[2]) * fy * (1 - fz) +
      (c[5] - c[4]) * (1 - fy) * fz +
      (c[7] - c[6]) * fy * fz
    const gy =
      (c[2] - c[0]) * (1 - fx) * (1 - fz) +
      (c[3] - c[1]) * fx * (1 - fz) +
      (c[6] - c[4]) * (1 - fx) * fz +
      (c[7] - c[5]) * fx * fz
    const gz =
      (c[4] - c[0]) * (1 - fx) * (1 - fy) +
      (c[5] - c[1]) * fx * (1 - fy) +
      (c[6] - c[2]) * (1 - fx) * fy +
      (c[7] - c[3]) * fx * fy
    const gl = len(gx, gy, gz) || 1
    let x = min[0] + (i + fx) * h
    let y = min[1] + (j + fy) * h
    let z = min[2] + (k + fz) * h
    const d = f(x, y, z)
    const lx = min[0] + i * h
    const ly = min[1] + j * h
    const lz = min[2] + k * h
    x = Math.min(Math.max(x - (d * gx) / gl, lx - h * 0.25), lx + h * 1.25)
    y = Math.min(Math.max(y - (d * gy) / gl, ly - h * 0.25), ly + h * 1.25)
    z = Math.min(Math.max(z - (d * gz) / gl, lz - h * 0.25), lz + h * 1.25)
    const idx = pos.length / 3
    pos.push(x, y, z)
    verts.set(key, idx)
    return idx
  }

  const tris: number[] = []
  const quad = (a: number, b: number, c: number, d: number, flip: boolean) => {
    if (flip) tris.push(a, c, b, a, d, c)
    else tris.push(a, b, c, a, c, d)
  }
  for (let n = 0; n < cells.length; n += 3) {
    if ((n / 3) % SLICE === 0) yield
    const i = cells[n]
    const j = cells[n + 1]
    const k = cells[n + 2]
    if (i < 1 || j < 1 || k < 1) continue
    const inside = val(i, j, k) < 0
    // Each sign change along a cell's lower edges joins the four cells sharing that edge.
    if (inside !== val(i + 1, j, k) < 0)
      quad(
        vertex(i, j - 1, k - 1),
        vertex(i, j, k - 1),
        vertex(i, j, k),
        vertex(i, j - 1, k),
        !inside,
      )
    if (inside !== val(i, j + 1, k) < 0)
      quad(
        vertex(i - 1, j, k - 1),
        vertex(i - 1, j, k),
        vertex(i, j, k),
        vertex(i, j, k - 1),
        !inside,
      )
    if (inside !== val(i, j, k + 1) < 0)
      quad(
        vertex(i - 1, j - 1, k),
        vertex(i, j - 1, k),
        vertex(i, j, k),
        vertex(i - 1, j, k),
        !inside,
      )
  }
  const positions = new Float32Array(pos)
  const indices = new Uint32Array(tris)
  return { positions, normals: faceNormals(positions, indices), indices }
}

/** Area-weighted vertex normals from the faces. */
export function faceNormals(p: Float32Array, idx: Uint32Array) {
  const out = new Float32Array(p.length)
  for (let t = 0; t < idx.length; t += 3) {
    const a = idx[t] * 3
    const b = idx[t + 1] * 3
    const c = idx[t + 2] * 3
    const ux = p[b] - p[a]
    const uy = p[b + 1] - p[a + 1]
    const uz = p[b + 2] - p[a + 2]
    const vx = p[c] - p[a]
    const vy = p[c + 1] - p[a + 1]
    const vz = p[c + 2] - p[a + 2]
    const nx = uy * vz - uz * vy
    const ny = uz * vx - ux * vz
    const nz = ux * vy - uy * vx
    for (const v of [a, b, c]) {
      out[v] += nx
      out[v + 1] += ny
      out[v + 2] += nz
    }
  }
  for (let v = 0; v < out.length; v += 3) {
    const l = len(out[v], out[v + 1], out[v + 2]) || 1
    out[v] /= l
    out[v + 1] /= l
    out[v + 2] /= l
  }
  return out
}

/** A field sampled lazily on a lattice and trilinearly interpolated: cheap, smooth, approximate. */
export function lattice(f: Field, h: number): Field {
  const cache = new Map<number, number>()
  const at = (i: number, j: number, k: number) => {
    const key = i + 4096 + 8192 * (j + 4096 + 8192 * (k + 4096))
    let v = cache.get(key)
    if (v === undefined) {
      v = f(i * h, j * h, k * h)
      cache.set(key, v)
    }
    return v
  }
  return (x, y, z) => {
    const gx = x / h
    const gy = y / h
    const gz = z / h
    const i = Math.floor(gx)
    const j = Math.floor(gy)
    const k = Math.floor(gz)
    const tx = gx - i
    const ty = gy - j
    const tz = gz - k
    const l = (a: number, b: number, t: number) => a + (b - a) * t
    return l(
      l(l(at(i, j, k), at(i + 1, j, k), tx), l(at(i, j + 1, k), at(i + 1, j + 1, k), tx), ty),
      l(
        l(at(i, j, k + 1), at(i + 1, j, k + 1), tx),
        l(at(i, j + 1, k + 1), at(i + 1, j + 1, k + 1), tx),
        ty,
      ),
      tz,
    )
  }
}

/** Run a generator to completion (tests and synchronous callers). */
export function finish<T>(gen: Generator<void, T>): T {
  for (;;) {
    const r = gen.next()
    if (r.done) return r.value
  }
}

/** Seeded value noise in [0, 1], for cloth folds, hair clumps, and skin mottling. */
export function noise3(seed: number): Field {
  const hash = (i: number, j: number, k: number) => {
    let h = (i * 374761393 + j * 668265263 + k * 2147483647 + seed * 974634281) | 0
    h = Math.imul(h ^ (h >>> 13), 1274126177)
    return ((h ^ (h >>> 16)) >>> 0) / 4294967295
  }
  const s = (t: number) => t * t * (3 - 2 * t)
  const l = (a: number, b: number, t: number) => a + (b - a) * t
  return (x, y, z) => {
    const i = Math.floor(x)
    const j = Math.floor(y)
    const k = Math.floor(z)
    const fx = s(x - i)
    const fy = s(y - j)
    const fz = s(z - k)
    return l(
      l(
        l(hash(i, j, k), hash(i + 1, j, k), fx),
        l(hash(i, j + 1, k), hash(i + 1, j + 1, k), fx),
        fy,
      ),
      l(
        l(hash(i, j, k + 1), hash(i + 1, j, k + 1), fx),
        l(hash(i, j + 1, k + 1), hash(i + 1, j + 1, k + 1), fx),
        fy,
      ),
      fz,
    )
  }
}

person/shape.ts

// A FigureSpec as signed-distance fields: the anatomy (tagged primitives on a skeleton), and every
// layer over it (clothes, hair, hats, accessories) as offsets of or shells around the body field.
import {
  box,
  clamp01,
  ellipsoid,
  type Field,
  len,
  noise3,
  type Prim,
  prim,
  program,
  roundCone,
  smax,
  smin,
  sphere,
  type V3,
} from './sdf'
import type { FigureSpec, Layer, MaterialKind } from './spec'

export const BONES = [
  'root',
  'pelvis',
  'spine',
  'chest',
  'neck',
  'head',
  'upperArm.L',
  'foreArm.L',
  'hand.L',
  'upperArm.R',
  'foreArm.R',
  'hand.R',
  'thigh.L',
  'shin.L',
  'foot.L',
  'thigh.R',
  'shin.R',
  'foot.R',
] as const
export type BoneName = (typeof BONES)[number]
export const PARENT = [-1, 0, 1, 2, 3, 4, 3, 6, 7, 3, 9, 10, 1, 12, 13, 1, 15, 16]
const B = (name: BoneName) => BONES.indexOf(name)

/** How far the arms stand out from the body in the bind pose, in radians. */
export const ARM_SPREAD = 0.26

/** `p` turned by `a` about the z axis through `pivot`. */
function turn(p: V3, pivot: V3, a: number): V3 {
  const dx = p[0] - pivot[0]
  const dy = p[1] - pivot[1]
  return [
    pivot[0] + dx * Math.cos(a) - dy * Math.sin(a),
    pivot[1] + dx * Math.sin(a) + dy * Math.cos(a),
    p[2],
  ]
}

/** A primitive turned about the z axis through `pivot` (distances are unchanged by rotation). */
function swing(q: Prim, pivot: V3, a: number): Prim {
  const c = Math.cos(a)
  const s = Math.sin(a)
  const d = q.d
  return {
    ...q,
    c: turn(q.c, pivot, a),
    d: (x, y, z) => {
      const dx = x - pivot[0]
      const dy = y - pivot[1]
      return d(pivot[0] + dx * c + dy * s, pivot[1] - dx * s + dy * c, z)
    },
  }
}

/** How a part is colored: a flat color, or a function of the surface point. */
export type Paint = string | ((x: number, y: number, z: number) => string | null)

export interface PartDef {
  name: string
  material: MaterialKind
  field: Field
  min: V3
  max: V3
  /** Cell size at full detail, in world units. `far` is the coarse size, or `null` to skip. */
  near: number
  far: number | null
  /** Primitives the skin weights are measured against, and the falloff. */
  weightBy: Prim[]
  sigma: number
  /** Below `y`, weigh against `prims` with `sigma` instead (a skirt follows hips and thighs
   *  loosely while the bodice above follows the torso and arms tightly). */
  below?: { y: number; maxX: number; prims: Prim[]; sigma: number }
  paint: Paint
  /** Only at full detail (the coarse build folds these into `skin`). */
  nearOnly?: boolean
  farOnly?: boolean
}

export interface Shape {
  joints: V3[]
  height: number
  eyes: { center: V3; radius: number }[]
  /** Radius of the blink lid shell (between the eyeball and the lid skin). */
  lidRadius: number
  parts: PartDef[]
  /** Everything solid, for baked occlusion. */
  occluder: Field
  /** Skin shading inputs (lips, flush, stubble) for the skin parts. */
  skinPaint: (x: number, y: number, z: number) => { lip: number; flush: number; stubble: number }
  /** Landmarks the rig and the far-detail fallbacks need. */
  marks: { hipY: number; thigh: number; shin: number; eyeY: number; headH: number; waistY: number }
  /** Glasses and the staff are rigid three.js primitives, positioned here. */
  props: {
    glasses?: { center: V3; ring: number; spacing: number; tube: number }
    staff?: { length: number; radius: number }
  }
}

/** Reference figure the measurements below were sculpted at: height below the chin, head height. */
const BODY0 = 1.385
const HEAD0 = 0.265

export function shapeFigure(spec: FigureSpec): Shape {
  const st = spec.st
  const fem = spec.fem
  const H = spec.height
  const headH = H / spec.headsTall
  const hu = headH / HEAD0
  const eyeY = H - headH * 0.548
  const chinY = eyeY - (0.116 + spec.face.chin) * hu
  const Bh = chinY
  const u = Bh / BODY0
  const girth = spec.build === 'slim' ? 0.86 : spec.build === 'stocky' ? 1.2 : 1
  const W = st.mass * girth * (spec.age === 'child' ? 0.92 : 1)
  const wide = 1.06 - 0.13 * fem
  const hipMul = 1 + 0.1 * fem
  const legs = st.legs
  const n = noise3(spec.seed)
  const lerp = (a: number, b: number, t: number) => a + (b - a) * t

  // ---- skeleton --------------------------------------------------------------------------------
  const shW = st.shoulder * Bh * wide * (0.94 + girth * 0.06)
  const hipX = 0.0505 * Bh * hipMul * (0.9 + girth * 0.1)
  const y = (f: number) => f * Bh
  const J: Record<BoneName, V3> = {
    root: [0, 0, 0],
    pelvis: [0, y(0.65 + legs), 0],
    spine: [0, y(0.736 + legs * 0.6), 0],
    chest: [0, y(0.837 + legs * 0.3), 0],
    neck: [0, y(0.96), -0.018 * u],
    head: [0, chinY + 0.017 * hu, -0.012 * hu],
    'upperArm.L': [shW, y(0.949), -0.012 * u],
    'foreArm.L': [shW + 0.0217 * Bh, y(0.765), -0.03 * u],
    'hand.L': [shW + 0.0318 * Bh, y(0.599), -0.008 * u],
    'upperArm.R': [-shW, y(0.949), -0.012 * u],
    'foreArm.R': [-(shW + 0.0217 * Bh), y(0.765), -0.03 * u],
    'hand.R': [-(shW + 0.0318 * Bh), y(0.599), -0.008 * u],
    'thigh.L': [hipX, y(0.617 + legs), 0],
    'shin.L': [hipX + 0.003 * Bh, y(0.339 + legs * 0.55), 0.01 * u],
    'foot.L': [hipX + 0.0043 * Bh, y(0.0505), -0.018 * u],
    'thigh.R': [-hipX, y(0.617 + legs), 0],
    'shin.R': [-(hipX + 0.003 * Bh), y(0.339 + legs * 0.55), 0.01 * u],
    'foot.R': [-(hipX + 0.0043 * Bh), y(0.0505), -0.018 * u],
  }
  const shoulderY = J['upperArm.L'][1]
  const hipY = J['thigh.L'][1]
  const kneeY = J['shin.L'][1]
  const ankleY = J['foot.L'][1]
  const pelvisY = J.pelvis[1]
  const waistY = pelvisY + 0.04 * u
  const neckTop = shoulderY + 0.055 * u

  // ---- primitive helpers -----------------------------------------------------------------------
  const body: Prim[] = []
  const head: Prim[] = []
  const hands: Prim[] = []
  /** Ellipsoid with radii scaled: x/z by girth, y by body scale. */
  const ell = (
    list: Prim[],
    bone: BoneName,
    c: V3,
    r: V3,
    k: number,
    extra?: Partial<Prim> & { rot?: V3 },
  ) => {
    list.push(prim(ellipsoid(c, r, extra?.rot), c, Math.max(...r), B(bone), k, extra))
  }
  const cone = (
    list: Prim[],
    bone: BoneName,
    a: V3,
    b: V3,
    r1: number,
    r2: number,
    k: number,
    extra?: Partial<Prim>,
  ) => {
    const c: V3 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]
    const R = len(b[0] - a[0], b[1] - a[1], b[2] - a[2]) / 2 + Math.max(r1, r2)
    list.push(prim(roundCone(a, b, r1, r2), c, R, B(bone), k, extra))
  }
  const sph = (
    list: Prim[],
    bone: BoneName,
    c: V3,
    r: number,
    k: number,
    extra?: Partial<Prim>,
  ) => {
    list.push(prim(sphere(c, r), c, r, B(bone), k, extra))
  }
  const bu = (x: number, yy: number, z: number): V3 => [x * u * W, yy, z * u * W]

  // ---- torso -----------------------------------------------------------------------------------
  ell(
    body,
    'pelvis',
    [0, pelvisY + 0.005 * u, -0.005 * u],
    [0.122 * u * W * hipMul, 0.08 * u, 0.088 * u * W],
    0,
  )
  for (const x of [-1, 1])
    ell(
      body,
      'pelvis',
      [x * 0.05 * u * W * hipMul, pelvisY - 0.05 * u, -0.038 * u * W],
      [0.064 * u * W * hipMul, 0.072 * u, 0.058 * u * W * hipMul],
      0.04 * u,
    )
  ell(
    body,
    'spine',
    [0, J.spine[1] + 0.01 * u, 0.004 * u],
    [0.104 * u * W * (1 - 0.06 * fem), 0.13 * u, 0.078 * u * W],
    0.07 * u,
  )
  ell(
    body,
    'chest',
    [0, J.chest[1] + 0.03 * u, -0.006 * u],
    [0.118 * u * W * wide, 0.145 * u, 0.082 * u * W],
    0.06 * u,
  )
  if (spec.age !== 'child') {
    for (const x of [-1, 1]) {
      if (fem > 0.5)
        ell(
          body,
          'chest',
          bu(x * 0.05, J.chest[1] + 0.012 * u, 0.052),
          [0.05 * u * W, 0.046 * u, 0.044 * u * W],
          0.04 * u,
        )
      else if (fem < 0.5)
        ell(
          body,
          'chest',
          bu(x * 0.052, J.chest[1] + 0.045 * u, 0.04),
          [0.06 * u * W, 0.045 * u, 0.04 * u * W],
          0.035 * u,
        )
    }
  }
  for (const x of [-1, 1])
    cone(
      body,
      'chest',
      [0, shoulderY + 0.03 * u, -0.03 * u],
      [x * (shW - 0.025 * u), shoulderY + 0.003 * u, -0.018 * u],
      0.033 * u * W,
      0.028 * u * W,
      0.04 * u,
    )
  const neckR = 0.037 * u * (1 - 0.1 * fem) * Math.sqrt(W)
  cone(
    body,
    'neck',
    [0, J.neck[1] - 0.03 * u, -0.02 * u],
    [0, J.head[1] + 0.025 * hu, -0.008 * hu],
    neckR,
    neckR * 0.92,
    0.03 * u,
  )

  // ---- limbs -----------------------------------------------------------------------------------
  for (const tag of ['L', 'R'] as const) {
    const x = tag === 'L' ? 1 : -1
    const sh = J[`upperArm.${tag}`]
    const el = J[`foreArm.${tag}`]
    const wr = J[`hand.${tag}`]
    ell(
      body,
      `upperArm.${tag}`,
      [sh[0] + x * 0.002 * u, sh[1] - 0.015 * u, sh[2]],
      [0.038 * u * W, 0.048 * u, 0.04 * u * W],
      0.03 * u,
    )
    cone(body, `upperArm.${tag}`, sh, el, 0.033 * u * W, 0.026 * u * W, 0.015 * u)
    cone(body, `foreArm.${tag}`, el, wr, 0.027 * u * W, 0.019 * u * W, 0.01 * u)
    const hp = J[`thigh.${tag}`]
    const kn = J[`shin.${tag}`]
    const an = J[`foot.${tag}`]
    cone(body, `thigh.${tag}`, hp, kn, 0.066 * u * W * (1 + 0.05 * fem), 0.042 * u * W, 0.045 * u)
    cone(body, `shin.${tag}`, kn, an, 0.041 * u * W, 0.024 * u * W, 0.015 * u)
    ell(
      body,
      `shin.${tag}`,
      [kn[0] + x * 0.002 * u, lerp(an[1], kn[1], 0.68), -0.018 * u * W],
      [0.038 * u * W, 0.09 * u, 0.036 * u * W],
      0.03 * u,
    )
    ell(
      body,
      `foot.${tag}`,
      [an[0], 0.04 * u, an[2] - 0.015 * u],
      [0.028 * u, 0.038 * u, 0.04 * u],
      0.025 * u,
    )
    ell(
      body,
      `foot.${tag}`,
      [an[0] + x * 0.003 * u, 0.028 * u, an[2] + 0.075 * u],
      [0.036 * u, 0.024 * u, 0.068 * u],
      0.035 * u,
    )

    // hand: palm toward the thigh, four slim fingers and a thumb
    const hs = 0.82 * u * W ** 0.3
    const [hx, hy, hz] = wr
    cone(
      hands,
      `foreArm.${tag}`,
      [hx - x * 0.002 * u, hy + 0.05 * u, hz],
      wr,
      0.021 * u * W,
      0.019 * u * W,
      0.008 * u,
    )
    ell(
      hands,
      `hand.${tag}`,
      [hx, hy - 0.04 * hs, hz + 0.004 * hs],
      [0.013 * hs, 0.04 * hs, 0.034 * hs],
      0.01 * hs,
    )
    for (const [fz, fl] of [
      [0.022, 0.07],
      [0.0075, 0.078],
      [-0.007, 0.074],
      [-0.021, 0.06],
    ] as const) {
      const a: V3 = [hx - x * 0.002 * hs, hy - 0.075 * hs, hz + fz * hs]
      const m: V3 = [hx - x * 0.008 * hs, hy - (0.075 + fl * 0.5) * hs, hz + (fz + 0.005) * hs]
      const b: V3 = [hx - x * 0.018 * hs, hy - (0.075 + fl) * hs, hz + (fz + 0.012) * hs]
      cone(hands, `hand.${tag}`, a, m, 0.0072 * hs, 0.0066 * hs, 0.003 * hs)
      cone(hands, `hand.${tag}`, m, b, 0.0066 * hs, 0.0057 * hs, 0.002 * hs)
    }
    const ta: V3 = [hx - x * 0.006 * hs, hy - 0.022 * hs, hz + 0.02 * hs]
    const tb: V3 = [hx - x * 0.022 * hs, hy - 0.075 * hs, hz + 0.042 * hs]
    cone(hands, `hand.${tag}`, ta, tb, 0.0105 * hs, 0.0068 * hs, 0.008 * hs)
  }

  // The arms are sculpted hanging, then swung out to the bind pose (a slight A), so cloth and
  // skin never bridge the gap between arm and torso. The rig swings them back down.
  for (const tag of ['L', 'R'] as const) {
    const pivot = J[`upperArm.${tag}`]
    const a = tag === 'L' ? ARM_SPREAD : -ARM_SPREAD
    const bones = new Set([B(`upperArm.${tag}`), B(`foreArm.${tag}`), B(`hand.${tag}`)])
    for (const list of [body, hands])
      for (let i = 0; i < list.length; i++)
        if (bones.has(list[i].bone)) list[i] = swing(list[i], pivot, a)
    J[`foreArm.${tag}`] = turn(J[`foreArm.${tag}`], pivot, a)
    J[`hand.${tag}`] = turn(J[`hand.${tag}`], pivot, a)
  }
  const joints = BONES.map((b) => J[b])
  const elbowY = J['foreArm.L'][1]
  const wristY = J['hand.L'][1]

  // ---- head (in head units around the eye-line centre) --------------------------------------
  const fc = spec.face
  const E = (x: number, yy: number, z: number): V3 => [x * hu, eyeY + yy * hu, z * hu]
  const hk = (k: number) => k * hu
  ell(head, 'head', E(0, 0.035, -0.012), [0.083 * hu, 0.11 * hu, 0.105 * hu], 0)
  ell(head, 'head', E(0, -0.03, 0.024), [0.073 * fc.cheek * hu, 0.066 * hu, 0.071 * hu], hk(0.04))
  const chinDrop = fc.chin
  for (const x of [-1, 1]) {
    cone(
      head,
      'head',
      E(x * 0.05 * fc.jaw, -0.045, -0.005),
      E(x * 0.016 * fc.jaw, -0.094 - chinDrop, 0.052),
      0.022 * fc.jaw * hu,
      0.017 * hu,
      hk(0.04),
    )
    cone(
      head,
      'head',
      E(x * 0.012, 0.031, 0.086),
      E(x * 0.058, 0.028, 0.066),
      0.008 * fc.brow * hu,
      0.007 * fc.brow * hu,
      hk(0.02),
    )
    ell(
      head,
      'head',
      E(x * 0.083, -0.012, -0.004),
      [0.011 * hu, 0.026 * hu, 0.017 * hu],
      hk(0.004),
      { rot: [0, 0, -x * 0.15], tag: 'ear' },
    )
  }
  ell(
    head,
    'head',
    E(0, -0.097 - chinDrop, 0.052),
    [0.022 * fc.jaw * hu, 0.019 * hu, 0.02 * hu],
    hk(0.03),
  )

  const eyeR = 0.0186 * fc.eye
  const eyeX = (0.031 + 0.006 * fc.eye) * fc.eyeX
  const eyeZ = 0.088 - eyeR
  for (const x of [-1, 1]) {
    ell(
      head,
      'head',
      E(x * eyeX, 0.003, eyeZ + eyeR * 1.13),
      [eyeR * 1.18 * hu, eyeR * 0.97 * hu, eyeR * 0.65 * hu],
      hk(eyeR * 0.65),
      { sub: true },
    )
    sph(head, 'head', E(x * eyeX, 0, eyeZ), eyeR * 1.065 * hu, hk(0.004), { tag: 'lid' })
    ell(
      head,
      'head',
      E(x * eyeX, eyeR * 0.67, eyeZ + eyeR * 0.65),
      [eyeR * 1.02 * hu, eyeR * 0.24 * hu, eyeR * 0.43 * hu],
      hk(0.005),
      { tag: 'lid' },
    )
    ell(
      head,
      'head',
      E(x * eyeX, -eyeR * 0.12, eyeZ + eyeR * 1.13),
      [eyeR * 1.065 * hu, eyeR * fc.lid * hu, eyeR * 0.6 * hu],
      hk(0.0022),
      { sub: true, rot: [0, 0, x * 0.05] },
    )
  }

  const ns = fc.nose
  const kind = fc.noseKind
  const tipR = (kind === 'broad' ? 0.0112 : kind === 'straight' ? 0.0086 : 0.0092) * ns
  const tipY = (kind === 'straight' ? -0.046 : -0.041) * ns
  const tipZ = 0.088 + (kind === 'straight' ? 0.016 : 0.012) * ns
  cone(
    head,
    'head',
    E(0, kind === 'straight' ? 0.004 : -0.004, 0.087),
    E(0, tipY + 0.003 * ns, tipZ + 0.003 * ns),
    0.0055 * ns * hu,
    (kind === 'straight' ? 0.0074 : 0.0082) * ns * hu,
    hk(0.012),
    { tag: 'nose' },
  )
  sph(head, 'head', E(0, tipY, tipZ), tipR * hu, hk(0.006), { tag: 'nose' })
  const alaX = (kind === 'broad' ? 0.0135 : 0.0105) * ns
  for (const x of [-1, 1]) {
    ell(
      head,
      'head',
      E(x * alaX, tipY - 0.004 * ns, 0.088 + 0.004 * ns),
      [0.0062 * ns * hu, 0.0058 * ns * hu, 0.0068 * ns * hu],
      hk(0.005),
      { tag: 'nose' },
    )
    ell(
      head,
      'head',
      E(x * alaX * 0.54, tipY - 0.0085 * ns, 0.088 + 0.0085 * ns),
      [0.0026 * ns * hu, 0.0016 * ns * hu, 0.0034 * ns * hu],
      hk(0.002),
      { sub: true },
    )
  }

  const m = fc.mouth
  const lips = fc.lips
  const my = -0.0635 - chinDrop * 0.4
  ell(
    head,
    'head',
    E(0, my + 0.0035, 0.0875),
    [0.015 * m * hu, 0.003 * lips * hu, 0.005 * hu],
    hk(0.006),
    { tag: 'lip' },
  )
  ell(
    head,
    'head',
    E(0, my - 0.004, 0.0855),
    [0.012 * m * hu, 0.0038 * lips * hu, 0.0055 * hu],
    hk(0.006),
    { tag: 'lip' },
  )
  ell(head, 'head', E(0, my, 0.091), [0.0155 * m * hu, 0.001 * hu, 0.009 * hu], hk(0.0025), {
    sub: true,
  })
  for (const x of [-1, 1]) {
    sph(head, 'head', E(x * 0.0148 * m, my + 0.0003, 0.0835), 0.0014 * hu, hk(0.003), { sub: true })
    ell(head, 'head', E(x * 0.091, -0.015, 0), [0.006 * hu, 0.011 * hu, 0.008 * hu], hk(0.003), {
      sub: true,
    })
  }
  head.push(...body.filter((p) => p.bone === B('neck')))

  // ---- skin fields -------------------------------------------------------------------------------
  const neckCut = chinY - 0.016 * u
  const wristCut = wristY + 0.008 * u
  const bodyF = program(body)
  const headF = program(head)
  const handF = program(hands)
  const armX = (shW + 0.118 * u * W * wide) / 2

  // Skin under opaque clothes is never seen, so it isn't meshed (keeps a clothed figure cheap).
  const tops = spec.layers.filter((l) => l.region === 'top' && l.type !== 'apron')
  const bottoms = spec.layers.filter((l) => l.region === 'bottom' && l.type !== 'skirt')
  const feet = spec.layers.filter((l) => l.region === 'feet')
  const margin = 0.035 * u
  const sleeveEnd = (l: Layer) =>
    l.sleeves === 'long'
      ? wristY + 0.012 * u
      : l.sleeves === 'short'
        ? shoulderY - 0.49 * (shoulderY - elbowY)
        : shoulderY + 1
  const hemOf = (l: Layer) =>
    l.tucked
      ? waistY - 0.03 * u
      : l.type === 'dress' || l.type === 'robe'
        ? waistY - l.length * (waistY - ankleY)
        : waistY - l.length * (waistY - kneeY)
  const legEnd = (l: Layer) => hipY - l.length * (hipY - ankleY - 0.025 * u)
  const torsoFrom = tops.length ? Math.min(...tops.map(hemOf)) + margin : Number.POSITIVE_INFINITY
  const torsoTo = tops.length
    ? Math.min(
        ...tops.map((l) =>
          l.neck === 'v' && !tops.some((o) => o !== l && o.neck !== 'v')
            ? shoulderY - 0.13 * u
            : shoulderY - 0.02 * u,
        ),
      )
    : Number.NEGATIVE_INFINITY
  const armFrom = tops.length ? Math.min(...tops.map(sleeveEnd)) + margin : Number.POSITIVE_INFINITY
  const legFrom = bottoms.length
    ? Math.min(...bottoms.map(legEnd)) + margin
    : Number.POSITIVE_INFINITY
  const legTo = bottoms.length ? waistY - margin : Number.NEGATIVE_INFINITY
  const shoeTop = feet.length ? Math.min(...feet.map(() => ankleY + 0.04 * u)) - 0.015 * u : -1
  const hidden = (x: number, yy: number) => {
    const ax = Math.abs(x)
    if (yy < shoeTop) return true
    if (yy > legFrom && yy < legTo && ax < armX - 0.01 * u) return true
    if (ax < armX - 0.005 * u) return yy > torsoFrom && yy < torsoTo
    return yy > armFrom && yy < shoulderY - 0.03 * u
  }
  const skinBody: Field = (x, yy, z) => {
    if (hidden(x, yy)) return Math.max(bodyF(x, yy, z), 0.004 * u)
    let d = smax(bodyF(x, yy, z), yy - neckCut, 0.004 * u)
    if (Math.abs(x) > armX) d = Math.max(d, wristCut - 0.012 * u - yy)
    return d
  }
  const skinHead: Field = (x, yy, z) =>
    smax(headF(x, yy, z) - 0.0004, neckCut - 0.018 * u - yy, 0.004 * u)
  const skinHands: Field = (x, yy, z) => smax(handF(x, yy, z) - 0.0003, yy - wristCut, 0.004 * u)
  const skinAll: Field = (x, yy, z) => {
    const d = Math.min(bodyF(x, yy, z), headF(x, yy, z), handF(x, yy, z))
    return hidden(x, yy) ? Math.max(d, 0.004 * u) : d
  }

  // ---- garments ----------------------------------------------------------------------------------
  const isLeg = (p: Prim) => /thigh|shin|foot/.test(BONES[p.bone])
  const torsoPrims = body.filter((p) => !isLeg(p) && !/Arm|hand|neck/.test(BONES[p.bone]))
  const upperArmPrims = body.filter((p) => /upperArm/.test(BONES[p.bone]))
  const foreArmPrims = body.filter((p) => /foreArm/.test(BONES[p.bone]))
  const legPrims = body.filter((p) => /pelvis|thigh|shin/.test(BONES[p.bone]))
  const footPrims = body.filter((p) => /foot|shin/.test(BONES[p.bone]))
  const pelvisPrims = body.filter((p) => /pelvis|spine/.test(BONES[p.bone]))
  const torsoF = program(torsoPrims)
  const legsF = program(legPrims)
  const feetF = program(footPrims)
  const pelvisF = program(pelvisPrims)
  const zc = -0.005 * u
  const hipRx = Math.max(0.122 * u * W * hipMul, hipX + 0.066 * u * W) + 0.01 * u
  const hipRz = 0.095 * u * W
  /** A flared elliptical drop from `top` to `hem`, wide enough to clear the thighs. */
  const skirt =
    (top: number, hem: number, off: number, flare: number): Field =>
    (x, yy, z) => {
      const t = Math.max(0, top - yy)
      const rx = hipRx + off + t * flare
      const rz = hipRz + off + t * flare * 0.8
      const d = (len(x / rx, (z - zc) / rz) - 1) * Math.min(rx, rz)
      return Math.max(d, yy - top, hem - yy)
    }
  const folds = (x: number, yy: number, z: number, amp: number) =>
    ((n((x * 13) / u, (yy * 6) / u, (z * 13) / u) - 0.5) * amp +
      (n((x * 31) / u + 9, (yy * 12) / u, (z * 31) / u) - 0.5) * amp * 0.3) *
    u

  const parts: PartDef[] = []
  const anatomy = [...body, ...head.filter((p) => p.bone === B('head')), ...hands]
  const pad = 0.05 * u
  const span = (x0: number, x1: number, y0: number, y1: number, z0: number, z1: number) => ({
    min: [x0, y0, z0] as V3,
    max: [x1, y1, z1] as V3,
  })
  const wideX = Math.max(shW + 0.09 * u * W, J['hand.L'][0] + 0.07 * u)
  const deepZ = 0.16 * u * W

  const garmentField = (
    l: Layer,
  ): {
    f: Field
    collar?: Field | null
    y0: number
    y1: number
    weightBy: Prim[]
    sigma: number
    below?: PartDef['below']
    flat: boolean
  } => {
    const off = l.offset * u
    const knit = l.material === 'knit'
    if (l.region === 'top') {
      const arms =
        l.sleeves === 'none'
          ? []
          : l.sleeves === 'long'
            ? [...upperArmPrims, ...foreArmPrims]
            : upperArmPrims
      const F = program([...torsoPrims, ...arms])
      const armHole = program(upperArmPrims, off + 0.016 * u)
      const sleeve = sleeveEnd(l)
      const hem = hemOf(l)
      const long = hem < hipY - 0.02 * u
      const flare =
        l.type === 'dress'
          ? 0.34
          : l.type === 'robe'
            ? 0.3
            : l.type === 'coat'
              ? 0.16
              : l.type === 'blazer'
                ? 0.1
                : l.type === 'apron'
                  ? 0.06
                  : 0.12
      const drop = long ? skirt(hipY + 0.03 * u, hem, off, flare) : null
      const loose = l.tucked ? 0 : 0.006
      const vBottom = shoulderY - 0.095 * u
      const f: Field = (x, yy, z) => {
        const inArm = Math.abs(x) > armX
        const ease =
          off + (inArm ? 0.0015 * u : clamp01((waistY + 0.1 * u - yy) / (0.3 * u)) * loose * u)
        let rib = 0
        if (knit && (yy < hem + 0.035 * u || (inArm && yy < sleeve + 0.03 * u)))
          rib = Math.sin(Math.atan2(x, z - zc) * 48) * 0.0012 * u
        let d = F(x, yy, z) - ease - rib + folds(x, yy, z, knit ? 0.002 : 0.005)
        if (l.sleeves === 'none') d = smax(d, -armHole(x, yy, z), 0.006 * u)
        d = smax(d, (inArm ? sleeve : long ? hipY : hem) - yy, 0.003 * u)
        if (drop) d = smin(d, drop(x, yy, z) + folds(x, yy, z, 0.006), 0.03 * u)
        // neckline
        d = smax(d, -(len(x, (z + 0.01 * u) * 1.15) - (neckR + 0.014 * u + off)), 0.008 * u)
        // A blazer's collar sits over a V opening: the lapels.
        if ((l.neck === 'v' || l.type === 'blazer') && z > 0)
          d = smax(
            d,
            yy - (vBottom - (l.type === 'blazer' ? 0.05 * u : 0) + Math.abs(x) * 2.2),
            0.004 * u,
          )
        d = smax(d, yy - neckTop, 0.006 * u)
        if (l.type === 'apron')
          d = Math.max(d, Math.abs(x) - 0.105 * u * W, 0.02 * u - z, yy - (shoulderY - 0.06 * u))
        return d
      }
      const y0 = (long ? hem : Math.min(hem, inArmMin(l))) - 0.02 * u
      // A fold-down collar: a band that flares as it drops, open at the front in a V so its two
      // points lie on the chest. Thin, so it meshes as its own finer part.
      // A hoodie's hood, down: a thick soft roll lying round the back of the neck, open at the
      // front where the drawstrings would hang.
      const hood: Field | null =
        l.type === 'hoodie'
          ? (x, yy, z) => {
              const r = neckR + 0.03 * u + off
              const ring = len(len(x, (z + 0.02 * u) * 1.1) - r, (yy - (neckTop - 0.012 * u)) * 1.3)
              return Math.max(ring - 0.024 * u, z - 0.03 * u + Math.abs(x) * 0.35)
            }
          : null
      const collar: Field | null = hood
        ? hood
        : l.neck === 'collar'
          ? (x, yy, z) => {
              const drop = clamp01((neckTop + 0.006 * u - yy) / (0.03 * u))
              const r = neckR + 0.016 * u + off + drop * 0.02 * u
              let c = Math.max(
                Math.abs(len(x, (z + 0.01 * u) * 1.12) - r) - 0.0035 * u,
                yy - (neckTop + 0.006 * u),
                neckTop - 0.026 * u - yy,
              )
              if (z > 0) c = Math.max(c, 0.006 * u + drop * 0.022 * u - Math.abs(x))
              return c
            }
          : null
      return {
        f,
        collar,
        y0,
        y1: neckTop + 0.03 * u,
        weightBy: [...torsoPrims, ...arms],
        sigma: 0.016 * u,
        below: long
          ? {
              y: hipY + 0.02 * u,
              maxX: armX,
              prims: [...pelvisPrims, ...legPrims.filter((p) => /thigh/.test(BONES[p.bone]))],
              sigma: 0.12 * u,
            }
          : undefined,
        flat: false,
      }
    }
    if (l.region === 'bottom') {
      if (l.type === 'skirt') {
        const hem = waistY - l.length * (waistY - ankleY)
        const drop = skirt(waistY, hem, off, 0.25)
        const f: Field = (x, yy, z) =>
          Math.min(
            drop(x, yy, z) + folds(x, yy, z, 0.008),
            Math.max(pelvisF(x, yy, z) - off, yy - waistY, hipY - yy),
          )
        return {
          f,
          y0: hem - 0.02 * u,
          y1: waistY + 0.02 * u,
          weightBy: [...pelvisPrims, ...legPrims.filter((p) => /thigh/.test(BONES[p.bone]))],
          sigma: 0.12 * u,
          flat: false,
        }
      }
      const end = legEnd(l)
      const cuff = l.type === 'jeans'
      const f: Field = (x, yy, z) => {
        const ease =
          off +
          clamp01((kneeY + 0.1 * u - yy) / (0.3 * u)) * 0.004 * u +
          (cuff && yy < end + 0.03 * u ? 0.004 * u : 0)
        let d = legsF(x, yy, z) - ease + folds(x, yy, z, 0.004)
        d = smax(d, yy - waistY, 0.003 * u)
        d = smax(d, end - yy, 0.003 * u)
        return d
      }
      return {
        f,
        y0: end - 0.02 * u,
        y1: waistY + 0.02 * u,
        weightBy: legPrims,
        sigma: 0.016 * u,
        flat: false,
      }
    }
    if (l.region === 'feet') {
      const top =
        l.type === 'boots'
          ? ankleY + 0.04 * u + l.length * (kneeY - ankleY - 0.08 * u)
          : ankleY + 0.04 * u
      const f: Field = (x, yy, z) => {
        let d = feetF(x, yy, z) - off
        d = Math.max(d, yy - top)
        const sole = Math.max(feetF(x, yy, z) - off - 0.003 * u, yy - 0.012 * u)
        return Math.max(Math.min(d, sole), -yy)
      }
      return {
        f,
        y0: -0.01,
        y1: top + 0.02 * u,
        weightBy: footPrims,
        sigma: 0.016 * u,
        flat: false,
      }
    }
    // extras
    if (l.type === 'belt') {
      const by = waistY - 0.015 * u
      const front = frontZ(pelvisF, by, off) + 0.002 * u
      const buckle = box([0, by, front], [0.016 * u, 0.013 * u, 0.003 * u], 0.002 * u)
      const f: Field = (x, yy, z) => {
        // a thin band, not a solid disc through the waist
        const d = pelvisF(x, yy, z) - off - 0.002 * u
        return Math.min(
          Math.max(d, -d - 0.005 * u, Math.abs(yy - by) - 0.011 * u),
          buckle(x, yy, z),
        )
      }
      return {
        f,
        y0: by - 0.03 * u,
        y1: by + 0.03 * u,
        weightBy: pelvisPrims,
        sigma: 0.02 * u,
        flat: true,
      }
    }
    if (l.type === 'scarf') {
      const sy = neckTop - 0.008 * u
      const tailA: V3 = [0.03 * u, sy - 0.01 * u, 0.07 * u * W]
      const tailB: V3 = [0.045 * u, sy - 0.05 * u - l.length * 0.2 * u, 0.1 * u * W]
      const tail = roundCone(tailA, tailB, 0.02 * u, 0.017 * u)
      const f: Field = (x, yy, z) => {
        const ring = len(len(x, (z + 0.01 * u) * 1.05) - (neckR + 0.03 * u), yy - sy) - 0.022 * u
        return (
          smin(ring, Math.max(tail(x, yy, z), -0.004 * u - (z - 0.04 * u)), 0.01 * u) +
          folds(x, yy, z, 0.006)
        )
      }
      return {
        f,
        y0: tailB[1] - 0.04 * u,
        y1: sy + 0.05 * u,
        weightBy: [...torsoPrims, ...body.filter((p) => p.bone === B('neck'))],
        sigma: 0.03 * u,
        flat: false,
      }
    }
    // cape: a thin shell hanging behind, from the shoulders toward the knees
    const top = shoulderY + 0.02 * u
    const hem = top - l.length * (top - kneeY)
    const f: Field = (x, yy, z) => {
      const t = clamp01((top - yy) / (top - hem))
      const rx = shW + 0.05 * u + t * 0.12 * u
      const rz = 0.11 * u * W + 0.02 * u + t * 0.08 * u
      const d = Math.abs((len(x / rx, (z + 0.01 * u) / rz) - 1) * Math.min(rx, rz)) - 0.004 * u
      return (
        Math.max(d, yy - top, hem - yy, z + 0.035 * u - (top - yy) * 0.05) + folds(x, yy, z, 0.006)
      )
    }
    return {
      f,
      y0: hem - 0.02 * u,
      y1: top + 0.05 * u,
      weightBy: [...torsoPrims, ...pelvisPrims],
      sigma: 0.06 * u,
      flat: false,
    }
  }
  function inArmMin(l: Layer) {
    return l.sleeves === 'none' ? shoulderY : sleeveEnd(l)
  }

  // Layers render from the stack; colors are flat apart from shoe soles and belt buckles.
  const garmentFields: Field[] = []
  spec.layers.forEach((l, i) => {
    const g = garmentField(l)
    garmentFields.push(g.f)
    const sole = l.region === 'feet' ? darken(l.color) : null
    const by = waistY - 0.015 * u
    parts.push({
      name: `${l.type}#${i}`,
      material: l.material,
      field: g.f,
      ...span(-wideX - pad, wideX + pad, Math.max(-0.01, g.y0), g.y1, -deepZ - pad, deepZ + pad),
      near:
        l.material === 'knit' || l.type === 'belt' ? 0.0048 : l.region === 'feet' ? 0.005 : 0.0068,
      far: l.type === 'belt' ? 0.012 : 0.024,
      weightBy: g.weightBy,
      sigma: g.sigma,
      below: g.below,
      paint:
        l.region === 'feet'
          ? (_x, yy) => (yy < 0.013 * u ? sole : null)
          : l.type === 'belt'
            ? (x, yy) =>
                Math.abs(x) < 0.017 * u && Math.abs(yy - by) < 0.014 * u ? spec.colors.metal : null
            : l.color,
    })
    parts[parts.length - 1].paint = paintWith(l.color, parts[parts.length - 1].paint)
    if (g.collar) {
      parts.push({
        name: `${l.type}#${i}.collar`,
        material: l.material,
        field: g.collar,
        min: [-0.12 * u, neckTop - 0.04 * u, -0.12 * u],
        max: [0.12 * u, neckTop + 0.02 * u, 0.12 * u],
        near: 0.0022,
        far: null,
        nearOnly: true,
        weightBy: [...torsoPrims, ...body.filter((p) => p.bone === B('neck'))],
        sigma: 0.02 * u,
        paint: l.color,
      })
    }
  })

  // ---- hair, brows, beard ----------------------------------------------------------------------
  const headOnly = head.filter((p) => p.bone === B('head'))
  const hairParts = hairFields(spec, E, hu, n, chinY)
  const headBox = (below: number, h0: number) => ({
    min: [-0.13 * hu, eyeY - below * hu, -0.16 * hu] as V3,
    max: [0.13 * hu, eyeY + h0 * hu, 0.15 * hu] as V3,
  })
  if (hairParts.hair) {
    const long = spec.hair === 'long' || spec.hair === 'braid' || spec.hair === 'ponytail'
    parts.push({
      name: 'hair',
      material: 'hair',
      field: hairParts.hair,
      ...headBox(long ? (spec.hair === 'braid' ? 0.75 : 0.5) : 0.12, 0.2),
      near: 0.0034,
      far: 0.012,
      weightBy: long
        ? [...headOnly, ...body.filter((p) => /neck|chest/.test(BONES[p.bone]))]
        : headOnly,
      sigma: long ? 0.05 * u : 0.02 * u,
      paint: spec.colors.hair,
    })
  }
  parts.push({
    name: 'brows',
    material: 'hair',
    field: hairParts.brows,
    ...headBox(-0.01, 0.07),
    near: 0.0015,
    far: null,
    weightBy: headOnly,
    sigma: 0.02 * u,
    paint: spec.colors.brow,
    nearOnly: true,
  })
  if (hairParts.beard) {
    parts.push({
      name: 'beard',
      material: 'hair',
      field: hairParts.beard,
      ...headBox(0.16, -0.02),
      near: 0.0028,
      far: 0.012,
      weightBy: headOnly,
      sigma: 0.02 * u,
      paint: spec.colors.hair,
    })
  }

  // ---- hats ------------------------------------------------------------------------------------
  const hat = hatField(spec, E, hu)
  if (hat) {
    parts.push({
      name: 'hat',
      material: spec.hat === 'straw' ? 'knit' : 'cloth',
      field: hat,
      min: [-0.27 * hu, eyeY - 0.2 * hu, -0.27 * hu],
      max: [0.27 * hu, eyeY + (spec.hat === 'chef' ? 0.32 : 0.26) * hu, 0.27 * hu],
      near: 0.0045,
      far: 0.014,
      weightBy:
        spec.hat === 'hood' ? [...headOnly, ...body.filter((p) => p.bone === B('neck'))] : headOnly,
      sigma: 0.03 * u,
      paint: spec.colors.hat,
    })
  }

  // ---- bag ---------------------------------------------------------------------------------------
  if (spec.accessories.includes('bag')) {
    const sx = -(shW + 0.02 * u)
    const sat = box(
      [-(shW + 0.035 * u), hipY + 0.01 * u, 0.0],
      [0.022 * u, 0.065 * u, 0.075 * u],
      0.012 * u,
    )
    const strapPlane = (x: number, yy: number) => (x - sx) * 0.62 - (yy - shoulderY) * 0.78
    const outer = spec.layers
      .filter((l) => l.region === 'top')
      .reduce((a, l) => Math.max(a, l.offset), 0)
    const f: Field = (x, yy, z) => {
      const strap = Math.max(
        Math.abs(torsoF(x, yy, z) - (outer + 0.006) * u) - 0.003 * u,
        Math.abs(strapPlane(x, yy)) - 0.018 * u,
      )
      return Math.min(strap, sat(x, yy, z))
    }
    parts.push({
      name: 'bag',
      material: 'leather',
      field: f,
      ...span(-wideX - 0.06 * u, wideX, hipY - 0.1 * u, neckTop, -deepZ, deepZ),
      near: 0.0055,
      far: 0.016,
      weightBy: [...torsoPrims, ...pelvisPrims],
      sigma: 0.05 * u,
      paint: spec.colors.bag,
    })
  }

  // ---- skin parts (first, so they draw under everything) -----------------------------------------
  const skin: PartDef[] = [
    {
      name: 'body',
      material: 'skin',
      field: skinBody,
      ...span(-wideX - pad, wideX + pad, -0.01, neckCut + 0.01 * u, -deepZ, deepZ),
      near: 0.0085,
      far: null,
      weightBy: anatomy,
      sigma: 0.016 * u,
      paint: spec.colors.skin,
      nearOnly: true,
    },
    {
      name: 'head',
      material: 'skin',
      field: skinHead,
      ...headBox(0.116 + spec.face.chin + 0.07, 0.14),
      near: 0.0024,
      far: null,
      weightBy: anatomy,
      sigma: 0.016 * u,
      paint: spec.colors.skin,
      nearOnly: true,
    },
    {
      name: 'hands',
      material: 'skin',
      field: skinHands,
      ...span(-wideX - pad, wideX + pad, wristY - 0.2 * u, wristCut + 0.01 * u, -0.1 * u, 0.1 * u),
      near: 0.0022,
      far: null,
      weightBy: anatomy,
      sigma: 0.016 * u,
      paint: spec.colors.skin,
      nearOnly: true,
    },
    {
      name: 'skin',
      material: 'skin',
      field: skinAll,
      ...span(-wideX - pad, wideX + pad, -0.01, H + 0.02, -deepZ - 0.04 * u, deepZ + 0.04 * u),
      near: 0.02,
      far: 0.02,
      weightBy: anatomy,
      sigma: 0.02 * u,
      paint: spec.colors.skin,
      farOnly: true,
    },
  ]
  parts.unshift(...skin)

  // ---- shading inputs ------------------------------------------------------------------------------
  const tagged = (tag: string) => program(head.filter((p) => p.tag === tag))
  const lipF = tagged('lip')
  const noseF = tagged('nose')
  const earF = tagged('ear')
  const cheeks = program(
    [-1, 1].map((x) =>
      prim(
        sphere(E(x * 0.048, -0.035, 0.075), 0.014 * hu),
        E(x * 0.048, -0.035, 0.075),
        0.014 * hu,
        0,
      ),
    ),
  )
  const near = (f: Field, x: number, yy: number, z: number, w: number) =>
    clamp01(1 - f(x, yy, z) / (w * hu))
  const stubble = spec.facialHair === 'stubble'
  const jawLine = chinY + 0.02 * hu
  const skinPaint = (x: number, yy: number, z: number) => {
    if (yy < neckCut - 0.03 * u) return { lip: 0, flush: 0, stubble: 0 }
    return {
      lip: near(lipF, x, yy, z, 0.002),
      flush:
        spec.face.blush *
        Math.max(
          near(cheeks, x, yy, z, 0.03) * 0.8,
          near(noseF, x, yy, z, 0.005) * 0.5,
          near(earF, x, yy, z, 0.004) * 0.6,
        ),
      stubble: stubble
        ? clamp01((eyeY - 0.055 * hu - yy) / (0.02 * hu)) *
          clamp01((yy - (jawLine - 0.05 * hu)) / (0.02 * hu)) *
          clamp01((z + 0.02 * hu) / (0.04 * hu)) *
          (1 - near(lipF, x, yy, z, 0.004))
        : 0,
    }
  }

  const occluder: Field = (x, yy, z) => {
    let d = Math.min(bodyF(x, yy, z), headF(x, yy, z), handF(x, yy, z))
    for (const l of spec.layers) {
      if (l.region === 'top') d = Math.min(d, torsoF(x, yy, z) - l.offset * u)
      else if (l.region === 'bottom') d = Math.min(d, legsF(x, yy, z) - l.offset * u)
    }
    return d
  }

  const glasses = spec.accessories.includes('glasses')
    ? {
        center: E(0, 0.002, 0.088 + 0.012),
        ring: eyeR * 1.45 * hu,
        spacing: eyeX * hu,
        tube: 0.0018 * hu,
      }
    : undefined
  const staff = spec.accessories.includes('staff')
    ? { length: H * 1.05, radius: 0.011 * u }
    : undefined

  return {
    joints,
    height: H,
    eyes: [-1, 1].map((x) => ({ center: E(x * eyeX, 0, eyeZ), radius: eyeR * 0.985 * hu })),
    lidRadius: eyeR * 1.035 * hu,
    parts,
    occluder,
    skinPaint,
    marks: { hipY, thigh: hipY - kneeY, shin: kneeY - ankleY, eyeY, headH, waistY },
    props: { glasses, staff },
  }
}

/** z of the surface of `f` straight ahead of the midline at height `y`, plus `off`. */
function frontZ(f: Field, y: number, off: number) {
  let lo = 0
  let hi = 0.5
  for (let i = 0; i < 24; i++) {
    const mid = (lo + hi) / 2
    if (f(0, y, mid) - off < 0) lo = mid
    else hi = mid
  }
  return lo
}

function darken(hex: string) {
  const v = Number.parseInt(hex.slice(1), 16)
  const c = (s: number) => Math.round(((v >> s) & 255) * 0.45)
  return `#${((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')}`
}

function paintWith(base: string, p: Paint): Paint {
  if (typeof p === 'string') return p
  return (x, y, z) => p(x, y, z) ?? base
}

type HeadPoint = (x: number, y: number, z: number) => V3

/** Hair, brows, and facial hair over the cranium. */
function hairFields(spec: FigureSpec, E: HeadPoint, hu: number, n: Field, chinY: number) {
  const style = spec.hair
  const eye = E(0, 0, 0)
  const toLocal = (x: number, y: number, z: number) => [x / hu, (y - eye[1]) / hu, z / hu] as const
  const fc = spec.face
  const eyeX = (0.031 + 0.006 * fc.eye) * fc.eyeX

  const bw = fc.browWeight
  const brows = program(
    [-1, 1].map((x) => {
      const a = E(x * (eyeX - 0.022), 0.038, 0.095)
      const b = E(x * (eyeX + 0.027), 0.042, 0.074)
      return prim(
        roundCone(a, b, 0.0046 * bw * hu, 0.0028 * bw * hu),
        a,
        len(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + 0.006 * hu,
        0,
      )
    }),
  )

  let hair: Field | null = null
  if (style !== 'bald') {
    const thick = style === 'cropped' ? 0.004 : 0.01
    const cap = ellipsoid(E(0, 0.04, -0.014), [
      (0.083 + thick) * hu,
      (0.11 + thick) * hu,
      (0.105 + thick) * hu,
    ])
    const extras: Field[] = []
    if (style === 'swept' || style === 'short') {
      const lobes: [V3, V3, V3][] =
        style === 'swept'
          ? [
              [
                [-0.05, 0.1, 0.07],
                [0.05, 0.035, 0.035],
                [0.3, 0, -0.5],
              ],
              [
                [0.0, 0.11, 0.075],
                [0.06, 0.03, 0.035],
                [0.35, 0, -0.35],
              ],
              [
                [0.05, 0.095, 0.065],
                [0.04, 0.03, 0.03],
                [0.3, 0, -0.2],
              ],
              [
                [0.07, 0.07, 0.03],
                [0.03, 0.04, 0.04],
                [0, 0, 0.3],
              ],
              [
                [-0.075, 0.07, 0.02],
                [0.03, 0.045, 0.045],
                [0, 0, -0.3],
              ],
            ]
          : [
              [
                [-0.035, 0.085, 0.075],
                [0.045, 0.022, 0.03],
                [0.5, 0, -0.25],
              ],
              [
                [0.025, 0.088, 0.078],
                [0.05, 0.022, 0.03],
                [0.5, 0, -0.1],
              ],
            ]
      extras.push(
        program(
          lobes.map(([c, r, rot]) =>
            prim(
              ellipsoid(E(...c), [r[0] * hu, r[1] * hu, r[2] * hu], rot),
              E(...c),
              Math.max(...r) * hu,
              0,
              0.02 * hu,
            ),
          ),
        ),
      )
    }
    if (style === 'long') {
      const back = ellipsoid(E(0, -0.07, -0.05), [0.098 * hu, 0.15 * hu, 0.075 * hu])
      const sides = program(
        [-1, 1].map((x) => {
          const a = E(x * 0.075, 0.02, 0.02)
          const b = E(x * 0.085, -0.15, -0.01)
          return prim(
            roundCone(a, b, 0.03 * hu, 0.026 * hu),
            a,
            len(0.01, 0.17, 0.03) * hu + 0.03 * hu,
            0,
            0.02 * hu,
          )
        }),
      )
      extras.push((x, y, z) => smin(back(x, y, z), sides(x, y, z), 0.03 * hu))
    }
    if (style === 'bun') extras.push(sphere(E(0, 0.1, -0.1), 0.045 * hu))
    if (style === 'ponytail') {
      const a = E(0, 0.04, -0.11)
      const m = E(0, -0.04, -0.14)
      const b = E(0, -0.2, -0.13)
      const t1 = roundCone(a, m, 0.03 * hu, 0.026 * hu)
      const t2 = roundCone(m, b, 0.026 * hu, 0.012 * hu)
      extras.push((x, y, z) => smin(t1(x, y, z), t2(x, y, z), 0.01 * hu))
    }
    if (style === 'braid') {
      const links = Array.from({ length: 9 }, (_, i) =>
        sphere(E(0, 0.02 - i * 0.032, -0.115 - Math.min(i, 2) * 0.008), (0.026 - i * 0.0015) * hu),
      )
      extras.push((x, y, z) => {
        let d = 1e9
        for (const l of links) d = smin(d, l(x, y, z), 0.006 * hu)
        return d
      })
    }
    const longHair = style === 'long'
    hair = (x, y, z) => {
      const [lx, ly, lz] = toLocal(x, y, z)
      const front = clamp01((lz + 0.02) / 0.09)
      // Hairline: high over the forehead, down at the nape, lifted over the ears.
      let line =
        0.06 * front -
        0.055 * (1 - front) +
        Math.max(0, 0.03 - Math.abs(Math.abs(lx) - 0.082) * 1.5) * 0.9
      if (longHair) line = Math.min(line, lz < 0.035 ? -0.3 : line)
      const ang = Math.atan2(lx, lz)
      const clumps =
        Math.sin(ang * 18 + ly * 30 + n(lx * 8, ly * 8, lz * 8) * 4) *
        (style === 'cropped' ? 0.0008 : 0.0026)
      let d = cap(x, y, z)
      for (const e of extras) d = smin(d, e(x, y, z), 0.015 * hu)
      d += clumps * hu
      d = smax(d, (line - ly) * hu, 0.01 * hu)
      if (longHair) {
        // keep the face clear and stop above the shoulders' front
        d = smax(
          d,
          -(len(lx * 1.1, (ly + 0.03) * 0.85) - 0.072) * hu - (lz > 0.03 ? 0 : 1),
          0.008 * hu,
        )
        d = smax(d, chinY - 0.09 * hu - y, 0.01 * hu)
      }
      return d
    }
  }

  let beard: Field | null = null
  if (spec.facialHair === 'beard' || spec.facialHair === 'moustache') {
    const chin = spec.face.chin
    const my = -0.0635 - chin * 0.4
    const stache = program(
      [-1, 1].map((x) => {
        const a = E(x * 0.002, my + 0.009, 0.092)
        const b = E(x * 0.022, my + 0.001, 0.084)
        return prim(roundCone(a, b, 0.0055 * hu, 0.0035 * hu), a, 0.03 * hu, 0)
      }),
    )
    if (spec.facialHair === 'moustache')
      beard = (x, y, z) => stache(x, y, z) + (n(x * 300, y * 300, z * 300) - 0.5) * 0.0008
    else {
      const jaw = ellipsoid(E(0, -0.075 - chin, 0.035), [
        0.07 * spec.face.jaw * hu,
        0.06 * hu,
        0.07 * hu,
      ])
      beard = (x, y, z) => {
        const [, ly, lz] = toLocal(x, y, z)
        let d = jaw(x, y, z) - 0.004 * hu
        d = smax(d, ly - (-0.03 - Math.max(0, -lz) * 0.4), 0.01 * hu)
        d = smax(d, -(lz + 0.02), 0.01 * hu)
        // mouth stays clear
        d = smax(d, -(len((x / hu) * 0.8, ly - my, (lz - 0.09) * 0.6) - 0.016) * hu, 0.004 * hu)
        d += (n(x * 200, y * 200, z * 200) - 0.5) * 0.0012 * hu
        return Math.min(d, stache(x, y, z))
      }
    }
  }
  return { hair, brows, beard }
}

function hatField(spec: FigureSpec, E: HeadPoint, hu: number): Field | null {
  const h = spec.hat
  if (h === 'none') return null
  const crown = ellipsoid(E(0, 0.045, -0.014), [0.097 * hu, 0.124 * hu, 0.12 * hu])
  const eye = E(0, 0, 0)[1]
  const ly = (y: number) => (y - eye) / hu
  if (h === 'cap') {
    const visor = ellipsoid(E(0, 0.072, 0.1), [0.075 * hu, 0.008 * hu, 0.07 * hu])
    return (x, y, z) => {
      const shell = Math.max(crown(x, y, z), (0.068 - ly(y)) * hu)
      return Math.min(shell, Math.max(visor(x, y, z), -(z / hu - 0.06) * hu))
    }
  }
  if (h === 'chef') {
    // A band round the head, a straight tube above it, and a soft puff on top.
    const puff = ellipsoid(E(0, 0.21, -0.02), [0.118 * hu, 0.072 * hu, 0.118 * hu])
    const cz = E(0, 0, -0.016)[2]
    return (x, y, z) => {
      const band = Math.max(crown(x, y, z) - 0.004 * hu, (0.06 - ly(y)) * hu)
      const tube = Math.max(len(x / hu, (z - cz) / hu) - 0.1, 0.06 - ly(y), ly(y) - 0.2) * hu
      return smin(Math.min(band, tube), puff(x, y, z), 0.025 * hu)
    }
  }
  if (h === 'bandana') {
    return (x, y, z) => {
      const tight = ellipsoid(E(0, 0.04, -0.014), [0.094 * hu, 0.12 * hu, 0.116 * hu])(x, y, z)
      const knot = sphere(E(0, 0.02, -0.12), 0.018 * hu)(x, y, z)
      return smin(
        Math.max(tight, (0.05 - ly(y) - Math.max(0, -z / hu) * 0.35) * hu),
        knot,
        0.01 * hu,
      )
    }
  }
  if (h === 'straw' || h === 'brim') {
    const wide = h === 'straw' ? 0.25 : 0.17
    const brimY = h === 'straw' ? 0.065 : 0.075
    const top = h === 'straw' ? 0.2 : 0.21
    return (x, y, z) => {
      const r = len(x / hu, (z - E(0, 0, -0.012)[2]) / hu)
      const crownTop = Math.max(crown(x, y, z) - 0.006 * hu, ly(y) - top, (brimY - ly(y)) * 1)
      const brim =
        Math.max(
          r - wide,
          Math.abs(ly(y) - brimY - (r - 0.1) * (h === 'straw' ? -0.05 : 0.12) * (r > 0.1 ? 1 : 0)) -
            0.004,
        ) * hu
      return Math.min(crownTop, brim)
    }
  }
  // hood: a shell over the head, open at the face, falling to the shoulders
  return (x, y, z) => {
    const outer = ellipsoid(E(0, 0.0, -0.03), [0.12 * hu, 0.175 * hu, 0.15 * hu])(x, y, z)
    const shell = Math.abs(outer + 0.006 * hu) - 0.006 * hu
    const [lx, lyy, lz] = [x / hu, ly(y), z / hu]
    let d = smax(
      shell,
      -(len(lx * 1.05, (lyy + 0.01) * 0.82) - 0.085) * hu - (lz > 0.02 ? 0 : 1),
      0.01 * hu,
    )
    d = smax(d, (-0.14 - lyy) * hu, 0.01 * hu)
    return d
  }
}

person/spec.ts

// Resolve a Person's props into a plain `FigureSpec`: every trait settled, every measurement
// derived. Pure, so the same props always give the same spec (and the same mesh).
import { pick, type Rng, range, rng, type WorldPalette } from '@runek/core'
import { Color } from 'three'
import type {
  PersonAccessory,
  PersonAge,
  PersonBodySpec,
  PersonBuild,
  PersonFaceSpec,
  PersonFacialHair,
  PersonGarment,
  PersonGarmentType,
  PersonGender,
  PersonHair,
  PersonHairSpec,
  PersonHat,
  PersonKind,
  PersonNose,
  PersonOutfit,
  PersonSkin,
  PersonStyle,
} from './types'

/**
 * The named skins that ship with the component: wardrobe, not wigs. Each sets clothes, colors,
 * headwear, and accessories but never hair, so a figure keeps its identity across skin swaps.
 * Colors are fixed on purpose: a named skin looks the same in every world (an explicit color
 * always beats the palette, CONTRACT §7). Pass the name to `skin`, or spread one into your own
 * bundle to tweak it.
 */
export const PERSON_SKINS = {
  /** Bright tunic and scarf for a market day or a fair. */
  festival: {
    outfit: 'tunic',
    topColor: '#c8563b',
    bottomColor: '#3f4a63',
    shoeColor: '#5a4632',
    hat: 'none',
    accessories: ['scarf', 'belt'],
  },
  /** Field clothes under a straw hat. */
  harvest: {
    outfit: 'shirt',
    topColor: '#c9a05a',
    bottomColor: '#6b5138',
    shoeColor: '#4a3a2a',
    hat: 'straw',
    accessories: ['belt'],
  },
  /** Hooded coat and scarf for the cold. */
  winter: {
    outfit: 'coat',
    topColor: '#54626f',
    bottomColor: '#38393f',
    shoeColor: '#2e2a26',
    hat: 'hood',
    accessories: ['scarf', 'belt'],
  },
  /** Deck shirt, bandana, and a shoulder bag for the crossing. */
  voyage: {
    outfit: 'shirt',
    topColor: '#e8e2d2',
    bottomColor: '#31456b',
    shoeColor: '#3a3128',
    hat: 'bandana',
    accessories: ['belt', 'bag'],
  },
  /** A deep-dyed robe and cape for rites and audiences. */
  ceremony: {
    outfit: 'robe',
    topColor: '#5b3a5e',
    bottomColor: '#2e2633',
    shoeColor: '#241f29',
    hat: 'none',
    accessories: ['cape'],
  },
  /** Workshop apron and cap. */
  atelier: {
    outfit: 'apron',
    topColor: '#7a6a55',
    bottomColor: '#4c4238',
    shoeColor: '#33302c',
    hat: 'cap',
    accessories: ['belt'],
  },
  /** Oxblood longcoat, dark bandana, and a plunder bag. */
  pirate: {
    outfit: 'coat',
    topColor: '#5c2a2e',
    bottomColor: '#2b2622',
    shoeColor: '#1f1b18',
    hat: 'bandana',
    accessories: ['belt', 'bag'],
  },
  /** Rolled white sleeves under the bar apron, black trousers. */
  bartender: {
    outfit: 'apron',
    topColor: '#e6e0d4',
    bottomColor: '#26242a',
    shoeColor: '#1d1b1f',
    hat: 'none',
    accessories: ['belt'],
  },
  /** Navy deck uniform with white trousers and a cap. */
  sailor: {
    outfit: 'uniform',
    topColor: '#31456b',
    bottomColor: '#e8e4d8',
    shoeColor: '#26221e',
    hat: 'cap',
    accessories: ['belt'],
  },
  /** Pale shirt under a charcoal blazer, dark trousers, polished shoes: a desk job. */
  office: {
    clothes: [
      { type: 'shirt', sleeves: 'long', neck: 'collar', color: '#e9edf1' },
      { type: 'trousers', color: '#2f3238' },
      { type: 'blazer', color: '#3d4350' },
      { type: 'belt', color: '#2a2220' },
      { type: 'shoes', color: '#1f1b19' },
    ],
    hat: 'none',
  },
  /** A plain tee, jeans, and sneakers. */
  casual: {
    clothes: [
      { type: 'tshirt', color: '#d9d4c8' },
      { type: 'jeans', color: '#4a5d78' },
      { type: 'shoes', color: '#e8e6e0' },
    ],
    hat: 'none',
  },
  /** A soft hoodie over jeans and sneakers. */
  hoodie: {
    clothes: [
      { type: 'jeans', color: '#3c4658' },
      { type: 'hoodie', color: '#6c7a5c' },
      { type: 'shoes', color: '#e3e0d8' },
    ],
    hat: 'none',
  },
  /** Kitchen whites: a chef's jacket, checked-dark trousers, an apron, and the tall hat. */
  chef: {
    clothes: [
      { type: 'trousers', color: '#34363b' },
      { type: 'shirt', sleeves: 'long', neck: 'crew', length: 0.4, color: '#f3f1ec' },
      { type: 'apron', color: '#efece4' },
      { type: 'shoes', color: '#26231f' },
    ],
    hat: 'chef',
  },
  /** White short-sleeved shirt under a green sweater vest, slim jeans, brown shoes. */
  preppy: {
    clothes: [
      { type: 'shirt', sleeves: 'short', neck: 'collar', color: '#f1eee7' },
      { type: 'vest', color: '#7f9a3f' },
      { type: 'jeans', color: '#45576a' },
      { type: 'belt', color: '#5a3522' },
      { type: 'shoes', color: '#9a5236' },
    ],
    hat: 'none',
  },
} satisfies Record<string, PersonSkin>

export type PersonSkinName = keyof typeof PERSON_SKINS

/** Everything a style template controls. Face metrics are multipliers over the stylized face. */
export interface StyleSpec {
  /** Heads per body height for an adult. */
  heads: number
  /** Shoulder joint half-width, as a fraction of the height below the chin. */
  shoulder: number
  /** Torso and limb girth. */
  mass: number
  /** Extra leg length, as a fraction of the height below the chin. */
  legs: number
  eye: number
  /** Lid opening height over eye radius. */
  lid: number
  nose: number
  mouth: number
  jaw: number
  /** Lower-face length added, in head units. */
  chin: number
  cheek: number
  brow: number
  nose0: PersonNose
}

export const STYLES: Record<PersonStyle, StyleSpec> = {
  stylized: {
    heads: 6.2,
    shoulder: 0.11,
    mass: 1,
    legs: 0,
    eye: 1,
    lid: 0.44,
    nose: 1,
    mouth: 1,
    jaw: 1,
    chin: 0,
    cheek: 1,
    brow: 1,
    nose0: 'button',
  },
  realistic: {
    heads: 7.5,
    shoulder: 0.122,
    mass: 1.18,
    legs: 0,
    eye: 0.74,
    lid: 0.36,
    nose: 1.25,
    mouth: 1.2,
    jaw: 1.06,
    chin: 0.012,
    cheek: 0.8,
    brow: 1.3,
    nose0: 'straight',
  },
  anime: {
    heads: 6.8,
    shoulder: 0.106,
    mass: 0.92,
    legs: 0.03,
    eye: 1.22,
    lid: 0.52,
    nose: 0.62,
    mouth: 0.78,
    jaw: 0.86,
    chin: 0.008,
    cheek: 0.9,
    brow: 0.8,
    nose0: 'button',
  },
}

const GENDERS: readonly PersonGender[] = ['feminine', 'masculine', 'neutral']
const BUILDS: readonly PersonBuild[] = ['slim', 'average', 'stocky']
const NOSES: readonly PersonNose[] = ['button', 'straight', 'broad']

// Skin and hair have no palette slot and no honest mapping onto one, so they roll from curated
// sets instead (the seeded book-spine precedent, CONTRACT §7). Any explicit color wins.
/** The curated colors a seeded `Person` rolls from: skin tones, hair colors, and eye colors. Handy
 *  as swatches in an editor; any other color works too. */
export const PERSON_COLORS = {
  skin: [
    '#f6ddc3',
    '#efc9a4',
    '#e9b896',
    '#e0aa7e',
    '#c98d63',
    '#a9714a',
    '#8a5a3b',
    '#65402a',
    '#48301f',
  ],
  hair: [
    '#171310',
    '#2b1d14',
    '#4a3122',
    '#6b4a2a',
    '#6e3d24',
    '#8c6b3f',
    '#b08d57',
    '#c9b18a',
    '#9a9a9a',
    '#7d3320',
  ],
  eyes: ['#4a3424', '#2f2a26', '#3d7ab8', '#3f6a48', '#6b5a2e', '#6b7a85'],
} as const
const SKIN_TONES = PERSON_COLORS.skin
const HAIR_COLORS = PERSON_COLORS.hair
const EYE_COLORS = PERSON_COLORS.eyes

const HAIR_STYLES: Record<PersonGender, readonly PersonHair[]> = {
  feminine: ['long', 'bun', 'ponytail', 'braid', 'swept', 'long', 'ponytail'],
  masculine: ['short', 'swept', 'cropped', 'short', 'swept', 'bald'],
  neutral: ['short', 'swept', 'cropped', 'ponytail', 'bun', 'long'],
}

export interface KindPreset {
  outfit: PersonOutfit
  hat: PersonHat
  accessories: PersonAccessory[]
}

export const KINDS: Record<PersonKind, KindPreset> = {
  villager: { outfit: 'tunic', hat: 'none', accessories: ['belt'] },
  merchant: { outfit: 'apron', hat: 'cap', accessories: ['bag', 'belt'] },
  guard: { outfit: 'uniform', hat: 'cap', accessories: ['belt'] },
  sailor: { outfit: 'shirt', hat: 'bandana', accessories: ['belt'] },
  farmer: { outfit: 'shirt', hat: 'straw', accessories: ['belt'] },
  noble: { outfit: 'coat', hat: 'brim', accessories: ['cape', 'belt'] },
  scholar: { outfit: 'robe', hat: 'hood', accessories: ['glasses', 'staff'] },
  traveler: { outfit: 'coat', hat: 'brim', accessories: ['bag', 'scarf'] },
}

/** What a one-word outfit expands to. `top`/`bottom`/`shoe` mark the layers the flat color
 *  props recolor; `light` is an inner layer in the palette's light `wall` tone. */
type Slot = 'top' | 'bottom' | 'shoe' | 'light' | 'apron'
const OUTFITS: Record<PersonOutfit, Array<PersonGarment & { slot: Slot }>> = {
  tunic: [
    { type: 'trousers', slot: 'bottom' },
    { type: 'tunic', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  shirt: [
    { type: 'shirt', sleeves: 'short', neck: 'collar', slot: 'top' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'shoes', slot: 'shoe' },
  ],
  dress: [
    { type: 'dress', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  robe: [
    { type: 'robe', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  coat: [
    { type: 'shirt', neck: 'collar', sleeves: 'long', slot: 'light' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'coat', slot: 'top' },
    { type: 'boots', slot: 'shoe' },
  ],
  apron: [
    { type: 'shirt', sleeves: 'short', neck: 'crew', slot: 'top' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'apron', slot: 'apron' },
    { type: 'shoes', slot: 'shoe' },
  ],
  uniform: [
    { type: 'trousers', slot: 'bottom' },
    { type: 'tunic', neck: 'collar', length: 0.45, slot: 'top' },
    { type: 'boots', slot: 'shoe' },
  ],
  vest: [
    { type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'vest', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  tshirt: [
    { type: 'tshirt', slot: 'top' },
    { type: 'jeans', slot: 'bottom' },
    { type: 'shoes', slot: 'shoe' },
  ],
  sweater: [
    { type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'sweater', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  hoodie: [
    { type: 'jeans', slot: 'bottom' },
    { type: 'hoodie', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
  blazer: [
    { type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
    { type: 'trousers', slot: 'bottom' },
    { type: 'blazer', slot: 'top' },
    { type: 'shoes', slot: 'shoe' },
  ],
}

export type GarmentRegion = 'top' | 'bottom' | 'feet' | 'extra'
export type MaterialKind = 'skin' | 'cloth' | 'knit' | 'denim' | 'leather' | 'hair'

export const GARMENT_REGION: Record<PersonGarmentType, GarmentRegion> = {
  tshirt: 'top',
  shirt: 'top',
  sweater: 'top',
  hoodie: 'top',
  blazer: 'top',
  vest: 'top',
  tunic: 'top',
  coat: 'top',
  dress: 'top',
  robe: 'top',
  apron: 'top',
  trousers: 'bottom',
  jeans: 'bottom',
  shorts: 'bottom',
  skirt: 'bottom',
  shoes: 'feet',
  boots: 'feet',
  belt: 'extra',
  scarf: 'extra',
  cape: 'extra',
}

export const GARMENT_MATERIAL: Record<PersonGarmentType, MaterialKind> = {
  tshirt: 'cloth',
  shirt: 'cloth',
  sweater: 'knit',
  hoodie: 'knit',
  blazer: 'cloth',
  vest: 'knit',
  tunic: 'cloth',
  coat: 'cloth',
  dress: 'cloth',
  robe: 'cloth',
  apron: 'cloth',
  trousers: 'cloth',
  jeans: 'denim',
  shorts: 'cloth',
  skirt: 'cloth',
  shoes: 'leather',
  boots: 'leather',
  belt: 'leather',
  scarf: 'knit',
  cape: 'cloth',
}

/** Per-type defaults: sleeves, neckline, and reach (0..1, meaning depends on the region). */
const GARMENT_DEFAULTS: Record<
  PersonGarmentType,
  { sleeves: 'none' | 'short' | 'long'; neck: 'crew' | 'v' | 'collar'; length: number }
> = {
  tshirt: { sleeves: 'short', neck: 'crew', length: 0.3 },
  shirt: { sleeves: 'long', neck: 'collar', length: 0.3 },
  sweater: { sleeves: 'long', neck: 'crew', length: 0.3 },
  hoodie: { sleeves: 'long', neck: 'crew', length: 0.36 },
  blazer: { sleeves: 'long', neck: 'collar', length: 0.42 },
  vest: { sleeves: 'none', neck: 'v', length: 0.15 },
  tunic: { sleeves: 'long', neck: 'crew', length: 0.6 },
  coat: { sleeves: 'long', neck: 'collar', length: 0.85 },
  dress: { sleeves: 'short', neck: 'crew', length: 0.6 },
  robe: { sleeves: 'long', neck: 'v', length: 1 },
  apron: { sleeves: 'none', neck: 'crew', length: 0.6 },
  trousers: { sleeves: 'none', neck: 'crew', length: 1 },
  jeans: { sleeves: 'none', neck: 'crew', length: 1 },
  shorts: { sleeves: 'none', neck: 'crew', length: 0.4 },
  skirt: { sleeves: 'none', neck: 'crew', length: 0.55 },
  shoes: { sleeves: 'none', neck: 'crew', length: 0 },
  boots: { sleeves: 'none', neck: 'crew', length: 0.45 },
  belt: { sleeves: 'none', neck: 'crew', length: 0 },
  scarf: { sleeves: 'none', neck: 'crew', length: 0.5 },
  cape: { sleeves: 'none', neck: 'crew', length: 0.8 },
}

/** A garment with every option settled, its place in the stack, and how far it stands off. */
export interface Layer {
  type: PersonGarmentType
  region: GarmentRegion
  material: MaterialKind
  color: string
  sleeves: 'none' | 'short' | 'long'
  neck: 'crew' | 'v' | 'collar'
  length: number
  /** A top tucked into a bottom listed after it. */
  tucked: boolean
  /** Base offset off the skin, in meters (scaled with the figure). */
  offset: number
}

export interface FaceMetrics {
  eye: number
  eyeX: number
  lid: number
  nose: number
  noseKind: PersonNose
  mouth: number
  lips: number
  jaw: number
  chin: number
  cheek: number
  brow: number
  browWeight: number
  blush: number
}

/** Everything the shape builder needs. Plain data: it is also the build cache key. */
export interface FigureSpec {
  seed: number
  style: PersonStyle
  gender: PersonGender
  age: PersonAge
  build: PersonBuild
  /** Standing height in world units. */
  height: number
  /** Feminine (1) to masculine (0) silhouette blend. */
  fem: number
  st: StyleSpec
  headsTall: number
  face: FaceMetrics
  hair: PersonHair
  facialHair: PersonFacialHair
  hat: PersonHat
  accessories: PersonAccessory[]
  layers: Layer[]
  colors: {
    skin: string
    lip: string
    eye: string
    brow: string
    hair: string
    hat: string
    bag: string
    accent: string
    metal: string
    wood: string
  }
}

/** The colors the flat `topColor` / `bottomColor` / `shoeColor` props stand for, settled. */
export interface SlotColors {
  top: string
  bottom: string
  shoe: string
}

/** Per-figure animation phases; never part of the mesh. */
export interface MotionSpec {
  phase: number
  blinkPhase: number
  blinkEvery: number
  stoop: number
}

export interface TraitInput {
  seed: number
  style: PersonStyle
  kind: PersonKind
  gender?: PersonGender
  age: PersonAge
  height?: number
  build?: PersonBuild
  skinTone?: string
  skin?: PersonSkinName | PersonSkin
  body?: PersonBodySpec
  face?: PersonFaceSpec
  hair?: PersonHair | PersonHairSpec
  hairColor?: string
  facialHair?: PersonFacialHair
  outfit?: PersonOutfit
  clothes?: PersonGarment[]
  topColor?: string
  bottomColor?: string
  shoeColor?: string
  hat?: PersonHat
  accessories?: PersonAccessory[]
}

const AGES: Record<PersonAge, { heads: number; stature: number; stoop: number; mass: number }> = {
  child: { heads: 0.74, stature: 0.72, stoop: 0, mass: 0.92 },
  adult: { heads: 1, stature: 1, stoop: 0, mass: 1 },
  elder: { heads: 0.98, stature: 0.97, stoop: 0.16, mass: 1 },
}
const BASE_HEIGHT: Record<PersonGender, number> = { feminine: 1.64, masculine: 1.76, neutral: 1.7 }

/** Nudge a palette color per figure, so a crowd defaulting to one slot isn't a uniform. */
function tint(hex: string, r: Rng): string {
  const c = new Color(hex)
  c.offsetHSL((r() - 0.5) * 0.05, (r() - 0.5) * 0.14, (r() - 0.5) * 0.16)
  return `#${c.getHexString()}`
}

function shade(hex: string, h: number, s: number, l: number) {
  return `#${new Color(hex).offsetHSL(h, s, l).getHexString()}`
}

/**
 * Every trait resolves the same way: the part object (`body`, `face`, `hair`, `clothes`) wins,
 * then the flat shorthand prop, then the skin, then the `kind` preset, then a seeded roll.
 */
export function resolveFigure(
  t: TraitInput,
  palette: WorldPalette,
  unit: number,
): { spec: FigureSpec; motion: MotionSpec; slots: SlotColors } {
  const r = rng(t.seed)
  const sk: PersonSkin | undefined = typeof t.skin === 'string' ? PERSON_SKINS[t.skin] : t.skin
  const hairObj = typeof t.hair === 'object' ? t.hair : undefined
  const hairName = typeof t.hair === 'string' ? t.hair : undefined

  // Every roll is drawn before any override is applied, so setting one trait never shifts the
  // rolls behind it.
  const roll = {
    gender: pick(r, GENDERS),
    build: pick(r, BUILDS),
    tone: pick(r, SKIN_TONES),
    hair: r(),
    hairColor: pick(r, HAIR_COLORS),
    beard: r(),
  }
  const gender = t.gender ?? roll.gender
  const build = t.body?.build ?? t.build ?? roll.build
  const tone = t.body?.tone ?? t.skinTone ?? roll.tone
  const styles = HAIR_STYLES[gender]
  const hair =
    hairObj?.style ?? hairName ?? sk?.hair ?? styles[Math.floor(roll.hair * styles.length)]
  const hairColor = hairObj?.color ?? t.hairColor ?? sk?.hairColor ?? roll.hairColor
  const beardRoll = roll.beard
  const facialHair =
    t.facialHair ??
    (gender === 'masculine' && t.age !== 'child' && beardRoll > 0.55
      ? beardRoll > 0.85
        ? 'beard'
        : beardRoll > 0.7
          ? 'moustache'
          : 'stubble'
      : 'none')
  // Clothes stay palette-driven (one palette swap re-themes the crowd), but each figure rolls
  // which slot it wears and a shade of it, or a village ends up in uniform.
  const topRoll = tint(pick(r, [palette.fabric, palette.accent, palette.wall, palette.foliage]), r)
  const bottomRoll = tint(pick(r, [palette.woodDark, palette.stone, palette.metal]), r)
  const eyeRoll = pick(r, EYE_COLORS)
  const noseRoll = pick(r, NOSES)
  const jitter = Array.from({ length: 6 }, () => range(r, -1, 1))
  const motion: MotionSpec = {
    phase: r() * Math.PI * 2,
    blinkPhase: r() * 6,
    blinkEvery: range(r, 3.4, 6.5),
    stoop: AGES[t.age].stoop,
  }

  const st = STYLES[t.style]
  const a = AGES[t.age]
  const preset = KINDS[t.kind]
  const height = (t.body?.height ?? t.height ?? BASE_HEIGHT[gender] * a.stature) * unit
  const child = t.age === 'child'
  const f = t.face ?? {}

  const face: FaceMetrics = {
    eye: st.eye * (child ? 1.12 : 1) * (f.eyes?.size ?? 1) * (1 + jitter[0] * 0.05),
    eyeX: 1 + jitter[1] * 0.035,
    lid: st.lid,
    nose: st.nose * (child ? 0.8 : 1) * (1 + jitter[2] * 0.08),
    noseKind: f.nose ?? (t.style === 'realistic' ? noseRoll : st.nose0),
    mouth: st.mouth * (1 + jitter[3] * 0.06),
    lips: (f.lips?.fullness ?? 1) * (gender === 'feminine' ? 1.2 : 1),
    jaw:
      st.jaw *
      (gender === 'masculine' ? 1.06 : gender === 'feminine' ? 0.93 : 1) *
      (1 + jitter[4] * 0.04),
    chin: st.chin + (child ? -0.008 : 0) + jitter[5] * 0.003,
    cheek: st.cheek * (child ? 1.1 : 1),
    brow: st.brow * (gender === 'masculine' ? 1.2 : 0.85),
    browWeight: f.brows?.weight ?? (gender === 'feminine' ? 0.7 : 0.85),
    blush: f.blush ?? (gender === 'feminine' || child ? 0.6 : 0.35),
  }

  const colors = {
    skin: tone,
    lip: f.lips?.color ?? shade(tone, -0.02, 0.1, -0.1),
    eye: f.eyes?.color ?? eyeRoll,
    brow: f.brows?.color ?? hairColor,
    hair: hairColor,
    hat: palette.accent,
    bag: palette.wood,
    accent: palette.accent,
    metal: palette.metal,
    wood: palette.wood,
  }

  const hat = t.hat ?? sk?.hat ?? preset.hat
  const slots: SlotColors = {
    top: t.topColor ?? sk?.topColor ?? topRoll,
    bottom: t.bottomColor ?? sk?.bottomColor ?? bottomRoll,
    shoe: t.shoeColor ?? sk?.shoeColor ?? palette.metal,
  }
  const layers = resolveLayers(t, sk, preset, palette, slots)

  const outerTop = [...layers].reverse().find((l) => l.region === 'top' && l.type !== 'apron')
  colors.hat =
    hat === 'straw'
      ? palette.sand
      : hat === 'brim'
        ? (layers.find((l) => l.region === 'bottom')?.color ?? palette.woodDark)
        : hat === 'bandana'
          ? palette.fabric
          : hat === 'hood'
            ? (outerTop?.color ?? palette.fabric)
            : hat === 'chef'
              ? '#f4f2ec'
              : palette.accent

  return {
    spec: {
      seed: t.seed,
      style: t.style,
      gender,
      age: t.age,
      build,
      height,
      fem: gender === 'feminine' ? 1 : gender === 'masculine' ? 0 : 0.5,
      st,
      headsTall: st.heads * a.heads,
      face,
      hair,
      facialHair,
      hat,
      accessories: (t.accessories ?? sk?.accessories ?? preset.accessories).filter(
        (x) => x === 'bag' || x === 'glasses' || x === 'staff',
      ),
      layers,
      colors,
    },
    motion,
    slots,
  }
}

function resolveLayers(
  t: TraitInput,
  sk: PersonSkin | undefined,
  preset: KindPreset,
  palette: WorldPalette,
  slot: SlotColors,
): Layer[] {
  const accessories = t.accessories ?? sk?.accessories ?? preset.accessories
  let stack: Array<PersonGarment & { slot?: Slot }>
  const explicit = t.clothes ?? sk?.clothes
  if (explicit) stack = explicit.map((g) => ({ ...g }))
  else {
    stack = OUTFITS[t.outfit ?? sk?.outfit ?? preset.outfit].map((g) => ({ ...g }))
    // Worn accessories that are really clothing become layers on the outside.
    for (const extra of ['belt', 'scarf', 'cape'] as const)
      if (accessories.includes(extra)) stack.push({ type: extra })
  }

  // The flat color props (already folded into `slot`) recolor the outermost top, every
  // bottom, and the footwear.
  let outerTop = -1
  stack.forEach((g, i) => {
    if (GARMENT_REGION[g.type] === 'top' && g.slot !== 'light' && g.type !== 'apron') outerTop = i
  })

  const light = shade(palette.wall, 0, -0.05, 0.12)
  const defaultColor = (g: PersonGarment & { slot?: Slot }, i: number, region: GarmentRegion) => {
    if (g.slot === 'light') return light
    if (g.type === 'apron') return palette.wall
    if (g.type === 'belt') return palette.woodDark
    if (g.type === 'scarf') return palette.accent
    if (g.type === 'cape') return slot.bottom
    if (region === 'feet') return slot.shoe
    if (region === 'bottom') return slot.bottom
    // The outermost top wears the top color; anything under it a light tone.
    return i === outerTop ? slot.top : light
  }

  const layers: Layer[] = stack.map((g, i) => {
    const d = GARMENT_DEFAULTS[g.type]
    const region = GARMENT_REGION[g.type]
    return {
      type: g.type,
      region,
      material: GARMENT_MATERIAL[g.type],
      // The garment's own color beats the shorthand color props, which beat the defaults.
      color: g.color ?? defaultColor(g, i, region),
      sleeves: g.sleeves ?? d.sleeves,
      neck: g.neck ?? d.neck,
      length: g.length ?? d.length,
      tucked: false,
      offset: 0,
    }
  })

  // Order decides the fit. A top listed before a bottom is tucked in; each layer stands just
  // proud of every earlier layer it overlaps, so inner never pokes through outer.
  const base: Record<GarmentRegion, number> = {
    top: 0.006,
    bottom: 0.008,
    feet: 0.009,
    extra: 0.01,
  }
  // How far past its base offset a layer can bulge (ease toward the hem, cuffs, folds).
  const slack = (l: Layer) =>
    l.region === 'bottom' ? 0.009 : l.region === 'top' ? (l.tucked ? 0.004 : 0.009) : 0.003
  const overlaps = (x: Layer, y: Layer) =>
    x.region === y.region ||
    (x.region === 'top' && y.region === 'bottom') ||
    (x.region === 'bottom' && y.region === 'top') ||
    (x.region === 'bottom' && y.region === 'feet') ||
    (x.region === 'feet' && y.region === 'bottom') ||
    x.region === 'extra' ||
    y.region === 'extra'
  layers.forEach((l, i) => {
    if (l.region === 'top' && !['dress', 'robe', 'coat', 'blazer', 'apron'].includes(l.type))
      l.tucked = layers.slice(i + 1).some((o) => o.region === 'bottom' && o.type !== 'skirt')
    let off =
      base[l.region] +
      (l.material === 'knit' ? 0.003 : 0) +
      (l.type === 'coat' || l.type === 'blazer' ? 0.008 : 0)
    for (let j = 0; j < i; j++)
      if (overlaps(l, layers[j])) off = Math.max(off, layers[j].offset + slack(layers[j]))
    l.offset = off
  })
  return layers
}

person/types.ts

// Public trait types for `Person`. All plain JSON, so a whole look round-trips through world data.

/** Preset bundles: clothes, hat, and accessories for a role. */
export type PersonKind =
  | 'villager'
  | 'merchant'
  | 'guard'
  | 'sailor'
  | 'farmer'
  | 'noble'
  | 'scholar'
  | 'traveler'
/** Silhouette cues only (shoulder-to-hip ratio, jaw, brow, default hair weights). */
export type PersonGender = 'feminine' | 'masculine' | 'neutral'
export type PersonAge = 'child' | 'adult' | 'elder'
export type PersonBuild = 'slim' | 'average' | 'stocky'
export type PersonHair =
  | 'short'
  | 'swept'
  | 'long'
  | 'bun'
  | 'ponytail'
  | 'braid'
  | 'cropped'
  | 'bald'
export type PersonFacialHair = 'none' | 'stubble' | 'moustache' | 'beard'
/** One-word outfit shorthand; it expands to a `clothes` stack. */
export type PersonOutfit =
  | 'tunic'
  | 'shirt'
  | 'dress'
  | 'robe'
  | 'coat'
  | 'apron'
  | 'uniform'
  | 'vest'
  | 'tshirt'
  | 'sweater'
  | 'hoodie'
  | 'blazer'
export type PersonHat = 'none' | 'cap' | 'straw' | 'brim' | 'bandana' | 'hood' | 'chef'
export type PersonAccessory = 'bag' | 'belt' | 'scarf' | 'glasses' | 'cape' | 'staff'
export type PersonPose =
  | 'stand'
  | 'sit'
  | 'lean'
  | 'work'
  | 'wave'
  | 'type'
  | 'play'
  | 'drink'
  | 'lie'
/** A small animated sign over the head: asleep, something went wrong, thinking, pleased, on a break. */
export type PersonEmote = 'sleep' | 'alert' | 'think' | 'happy' | 'coffee'
/** `auto` builds the full-detail mesh only within `LOD_NEAR` units of the camera. */
export type PersonDetail = 'auto' | 'high' | 'low'
/** Proportion and face template: `stylized` is an animated-film look (big head and eyes,
 *  small features, slender limbs), `realistic` keeps canonical human proportions, `anime`
 *  pushes the eyes and legs further and sharpens the chin. */
export type PersonStyle = 'stylized' | 'realistic' | 'anime'

/** The body itself. Every field is optional and seeded when unset. */
export interface PersonBodySpec {
  /** Limb thickness and torso depth. */
  build?: PersonBuild
  /** Skin color. */
  tone?: string
  /** Standing height, in units. */
  height?: number
}

export type PersonNose = 'button' | 'straight' | 'broad'

/** The face, part by part. Sizes are multipliers around 1. */
export interface PersonFaceSpec {
  eyes?: { color?: string; size?: number }
  /** Brow color defaults to the hair color; `weight` thickens them. */
  brows?: { color?: string; weight?: number }
  nose?: PersonNose
  lips?: { color?: string; fullness?: number }
  /** Cheek flush, 0 (none) to 1. */
  blush?: number
}

export interface PersonHairSpec {
  style?: PersonHair
  color?: string
}

export type PersonGarmentType =
  | 'tshirt'
  | 'shirt'
  | 'sweater'
  | 'hoodie'
  | 'blazer'
  | 'vest'
  | 'tunic'
  | 'coat'
  | 'dress'
  | 'robe'
  | 'apron'
  | 'trousers'
  | 'jeans'
  | 'shorts'
  | 'skirt'
  | 'shoes'
  | 'boots'
  | 'belt'
  | 'scarf'
  | 'cape'

/**
 * One layer of clothing. Layers are listed inner to outer, and the order decides the fit: a
 * top listed before the trousers is tucked into them, one listed after hangs over them.
 */
export interface PersonGarment {
  type: PersonGarmentType
  color?: string
  /** Tops only. */
  sleeves?: 'none' | 'short' | 'long'
  /** Tops only. */
  neck?: 'crew' | 'v' | 'collar'
  /** 0 to 1: how far it reaches (a top's hem, a leg's length, a skirt or boot's drop). */
  length?: number
}

/**
 * A cosmetic loadout (a "skin" in the game sense): clothes, colors, headwear, hair, and
 * body accessories bundled as one plain-JSON object, so a whole look swaps in a single
 * prop write. It never touches the body itself (tone, build, age, gender). Precedence per
 * trait: an explicit component prop > this skin > the `kind` preset > the seeded roll.
 */
export interface PersonSkin {
  outfit?: PersonOutfit
  /** Full layer stack; wins over `outfit`. */
  clothes?: PersonGarment[]
  topColor?: string
  bottomColor?: string
  shoeColor?: string
  hat?: PersonHat
  hair?: PersonHair
  hairColor?: string
  accessories?: PersonAccessory[]
}