# anatomy

Build crafted, highly detailed, interactive isometric SVG figures that explain a technical idea as a real physical object (a test rig, a dial gauge, a cabinet of drawers, a plotter, a lock), with many small precise solid parts, four-tone shading, correct painter's order, a framed card with title, hint and live readout, and calm eased motion. Use this whenever someone wants an isometric illustration, an explanatory diagram or figure for docs, a blog post, a landing page or an article, a "Linear-style" or "premium" technical drawing, an interactive explainer, or an SVG that shows how a system, algorithm or component works, even if they never say "isometric". Works framework-free (TypeScript/JS that writes SVG and HTML) and in React/Next.js, and can add realistic WebGL shaders (fire, exhaust, water, caustics, lamp light) that live inside the drawing in the same isometric perspective.

- **Kind:** skill
- **Source:** https://github.com/wheresryan22/anatomy
- **Page:** https://forefy.com/skills/1a0047e4-b2df-4471-bb42-729a099e2590
- **API (JSON + files):** https://forefy.com/api/asr/1a0047e4-b2df-4471-bb42-729a099e2590

---

## .gitattributes

```

```

## .github

```

```

## .github/ISSUE_TEMPLATE

```

```

## .github/ISSUE_TEMPLATE/bug_report.yml

```yaml
name: Bug report
description: A figure, the kit, a check script or the install did something wrong.
title: "Bug: "
labels: ["bug"]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for reporting. A screenshot helps most, and for a drawing fault a 4× close-up (`node scripts/capture.mjs page.html --zoom x,y,w,h`).
  - type: dropdown
    id: area
    attributes:
      label: Where is the problem?
      options:
        - A figure Claude made with the skill
        - One of the examples
        - The kit (kit/)
        - A check script (scripts/)
        - SKILL.md or a reference
        - Installing the skill
    validations:
      required: true
  - type: dropdown
    id: kind
    attributes:
      label: Kind of figure
      options:
        - SVG
        - SVG + WebGL
        - 3D (beta)
        - Not about a figure
  - type: dropdown
    id: target
    attributes:
      label: Where does the figure live?
      options:
        - Standalone HTML
        - A lone SVG file
        - React / Next.js
        - Not about a figure
  - type: dropdown
    id: theme
    attributes:
      label: Theme
      options:
        - Light
        - Dark
        - Both
        - Not about a figure
  - type: textarea
    id: what
    attributes:
      label: What happened
      description: What you saw, and where in the figure or the output.
    validations:
      required: true
  - type: textarea
    id: expected
    attributes:
      label: What you expected
    validations:
      required: true
  - type: textarea
    id: steps
    attributes:
      label: How to reproduce
      description: The prompt you gave Claude, or the exact command you ran.
      placeholder: |
        /anatomy explain how a token bucket rate limiter works, interactive
        node examples/raptor-engine/build.mjs --audit
    validations:
      required: true
  - type: textarea
    id: output
    attributes:
      label: Output
      description: Any error, audit lines or console messages.
      render: shell
  - type: input
    id: environment
    attributes:
      label: Environment
      description: Node.js version, operating system, Chrome version, Claude Code version.
      placeholder: output of `node --version` and `claude --version`, your OS, your Chrome version
    validations:
      required: true
  - type: textarea
    id: screenshots
    attributes:
      label: Screenshots
      description: Drag images here. Close-ups at 4× for drawing faults.
```

## .github/ISSUE_TEMPLATE/config.yml

```yaml
blank_issues_enabled: false
contact_links:
  - name: Security problem
    url: https://github.com/wheresryan22/anatomy/security/advisories/new
    about: Report security problems privately, not in a public issue.
```

## .github/ISSUE_TEMPLATE/figure_request.yml

```yaml
name: Figure or feature request
description: Ask for a new example figure, a new part or helper in the kit, a new check, or a change to how the skill works.
labels: ["enhancement"]
body:
  - type: markdown
    attributes:
      value: |
        Anatomy explains an idea as a real machine, instrument, tool or piece of furniture. It is not a chart library, and it does not draw people or exact maps.
  - type: dropdown
    id: request
    attributes:
      label: What are you asking for?
      options:
        - A new example figure
        - A new part or helper in the kit
        - A new check or script
        - A change to the skill's instructions
        - Something else
    validations:
      required: true
  - type: textarea
    id: idea
    attributes:
      label: The idea
      description: For a figure, name the idea it should explain and its real numbers (sizes, counts, rates, thresholds), or the object you have in mind and what should move. For anything else, describe the change.
      placeholder: A connection pool of 10 connections; requests borrow one and give it back, and wait in a queue when all 10 are busy.
    validations:
      required: true
  - type: dropdown
    id: kind
    attributes:
      label: Kind of figure
      options:
        - SVG
        - SVG + WebGL
        - 3D (beta)
        - Not sure
        - Not about a figure
  - type: textarea
    id: why
    attributes:
      label: Why it is worth adding
      description: What it would teach, or what you could not do without it.
    validations:
      required: true
  - type: textarea
    id: alternatives
    attributes:
      label: What you tried instead
  - type: checkboxes
    id: checks
    attributes:
      label: Before you submit
      options:
        - label: I searched the existing issues.
          required: true
        - label: I read the Limits section of the README.
          required: true
```

## .github/PULL_REQUEST_TEMPLATE.md

## What this changes



## Why



## Checks

Tick what applies, and say why for anything left unticked.

- [ ] Every example rebuilds, and `git status --short` shows only the changes I meant.
- [ ] No comments in code.
- [ ] Kit changes are opt-in, and every existing example is byte-identical (or this PR says which pages change and why).
- [ ] I built and looked at both themes.
- [ ] Figures with pipes, cables or round parts: `node build.mjs --audit` ends `audit passed`, every failing line at 0.
- [ ] `node scripts/lines.mjs` prints `line ends: 0` on both themes and in every resting pose.
- [ ] Shaders: a slowed contact sheet across every transition shows no pop, and `--no-webgl` still reads.
- [ ] 3D: a full run ends `turn audit passed`, and `scripts/turn-check.mjs` passes.
- [ ] Every number in the figure, the readout, the caption and the docs is true.
- [ ] A line under `Unreleased` in `CHANGELOG.md`.

## Screenshots

Light and dark, plus 4× close-ups of every joint the change touches.

## .github/assets

```

```

## .github/assets/dial-indicator.webp

```

```

## .github/assets/raptor-engine-apart.webp

```

```

## .github/assets/raptor-engine-firing.webp

```

```

## .github/assets/ripple-tank.webp

```

```

## .github/assets/turning-dial.webp

```

```

## .github/workflows

```

```

## .github/workflows/ci.yml

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  examples:
    name: Rebuild the examples
    runs-on: macos-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 22

      - name: Dial indicator
        run: |
          node examples/dial-indicator/build.mjs
          node examples/dial-indicator/build.mjs --light

      - name: Desk computer
        run: |
          node examples/desk-computer/build.mjs
          node examples/desk-computer/build.mjs --light

      - name: Arcade cabinet
        run: |
          node examples/arcade-cabinet/build.mjs
          node examples/arcade-cabinet/build.mjs --light

      - name: Ripple tank
        run: |
          node examples/ripple-tank/build.mjs
          node examples/ripple-tank/build.mjs --light

      - name: Turning dial
        run: |
          node examples/turning-dial/build.mjs
          node examples/turning-dial/build.mjs --light
          node examples/turning-dial/build.mjs --light --verify
          node examples/turning-dial/stress.mjs --light

      - name: Raptor engine
        run: |
          node examples/raptor-engine/build.mjs
          node examples/raptor-engine/build.mjs --light

      - name: Committed pages are unchanged
        run: |
          git status --short
          git diff --stat --exit-code
          test -z "$(git status --porcelain)"
```

## .gitignore

```

```

## CHANGELOG.md

# Changelog

All notable changes to Anatomy are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The skill has no version numbers yet, so entries are dated.

## [Unreleased]

### Added

- `examples/arcade-cabinet/`, an upright arcade cabinet with its side panel off. A quarter rolls down the coin chute past the coin-switch wire into the cash box, the switch's pulse runs along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor; the stick closes a microswitch under the panel. It draws in the light and dark themes and passes `--audit`.
- `examples/spot-plate/`, a second React figure from the same site as `test-rig`: six pairs of glass drops that run together into one piece of glass as a fan of feeler blades sets `merge`. It draws in the light and dark themes.
- A first workflow step, **Size it before you start**: every request is sized as a Figure (about 30–90 minutes), a Hero (2–4 hours) or an Epic (6 hours or more), and the estimate is told to the user first. An Epic is not started until the user chooses it over a proposed Hero version. Every size has a working page within the first hour, and Claude reports when a step runs past twice its share of the estimate.

### Fixed

- `examples/test-rig/` drew its glass button dark in the light theme. `stretch.css` now has light values for its own colours.

### Changed

- The workflow steps are renumbered 1–10.
- The 4× detail sweep scales with the size: the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic. The density rule itself still holds for every figure.

## 2026-10-08

### Changed

- The skill is now called Anatomy: `name: anatomy` in `SKILL.md`, invoked as `/anatomy`. It was developed privately as `isometric-objects` before this date, without releases.
- The CSS tokens are now `--anatomy-*` (for example `--anatomy-card`, `--anatomy-face`, `--anatomy-shade-0`); they were `--iso-*`. Every example is rebuilt with the new names.
- The figure card (`.iso-plate`) is square and flat: no rounded corners and no floating shadow, just a 1 px rule in the drawing's mid tone.
- `SKILL.md` makes high detail a standing rule for every figure, raises the parts list to at least 40 parts (60–150 for a hero figure), and adds a 4× detail pass to the final check.

### Added

- The public repository: a README with screenshots of the examples, the MIT license, a contributing guide, a code of conduct, a security policy, issue forms, a pull request template, and a CI workflow that rebuilds every example and fails if a committed page changes.
- What the skill contains at this point: the workflow and craft rules in `SKILL.md`; the kit (SVG core, round parts, pipes, the geometry audit, the WebGL layer, React components, and the 3D mode in beta); eight references; the headless Chrome checks in `scripts/`; six examples (`dial-indicator`, `desk-computer`, `raptor-engine`, `ripple-tank`, `test-rig`, `turning-dial`); and five evals.

## CODE_OF_CONDUCT.md

# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.

## Our Standards

Examples of behavior that contributes to a positive environment for our
community include:

* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
  and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
  community

Examples of unacceptable behavior include:

* The use of sexualized language or imagery, and sexual attention or advances of
  any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
  without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.

Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.

## Scope

This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement by direct message
to [@wheresryan22](https://x.com/wheresryan22) on X, or, when the report does
not need to be private, through an issue on the
[GitHub repository](https://github.com/wheresryan22/anatomy/issues).
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
reporter of any incident.

## Enforcement Guidelines

Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:

### 1. Correction

**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.

**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.

### 2. Warning

**Community Impact**: A violation through a single incident or series of
actions.

**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.

### 3. Temporary Ban

**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.

**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.

### 4. Permanent Ban

**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.

**Consequence**: A permanent ban from any sort of public interaction within the
community.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].

Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].

For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].

[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

## CONTRIBUTING.md

# Contributing to Anatomy

Thank you for helping. Anatomy is judged by how its figures look at 4× zoom, so most of this guide is about checking your work before anyone else does.

By taking part you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md).

## Setup

You need:

- **Node.js 20.10 or later.** Nothing to install: the kit, the build scripts and the checks use only Node's built-in modules.
- **Google Chrome or Chromium** for the browser checks. The scripts find it in the usual places; otherwise set `CHROME_PATH` to the binary.
- **Claude Code**, to try the skill itself.

```sh
git clone https://github.com/wheresryan22/anatomy.git
cd anatomy
```

To use your working copy as the skill while you change it, link it into your personal skills folder (move any installed copy out of the way first):

```sh
ln -s "$PWD" ~/.claude/skills/anatomy
```

## Building the examples

Each framework-free example is one script that writes standalone pages next to itself. The built pages are committed.

```sh
node examples/dial-indicator/build.mjs
node examples/dial-indicator/build.mjs --light
node examples/desk-computer/build.mjs
node examples/desk-computer/build.mjs --light
node examples/arcade-cabinet/build.mjs
node examples/arcade-cabinet/build.mjs --light
node examples/ripple-tank/build.mjs
node examples/ripple-tank/build.mjs --light
node examples/raptor-engine/build.mjs
node examples/raptor-engine/build.mjs --light
node examples/turning-dial/build.mjs
node examples/turning-dial/build.mjs --light
node examples/turning-dial/build.mjs --light --verify
node examples/turning-dial/stress.mjs --light
```

The Raptor builds take a few minutes each; the rest take seconds. `examples/test-rig/` and `examples/spot-plate/` are React components with no build script.

After a build, `git status --short` must show nothing unless you meant to change a figure. CI runs exactly these commands and then `git diff --exit-code`.

## The checks

Run the ones that match what you touched, and read every PNG they write: open it and look. Put screenshots in `shots/`, which is ignored.

**Geometry audit.** For figures with pipes, cables or round parts. It must end `audit passed`, with every failing line at 0.

```sh
node examples/raptor-engine/build.mjs --audit
```

**Line ends.** Every seam, rib, tick and pointer must end on a stroke, a dot or an outline. It must print `line ends: 0`; `--out` rings the failures.

```sh
node scripts/lines.mjs examples/dial-indicator/dial-indicator-light.html --out shots/lines-light.png
node scripts/lines.mjs examples/dial-indicator/dial-indicator.html --out shots/lines.png
```

**Capture.** Stills, phone width, hover, keys and 4× close-ups, with WebGL on.

```sh
node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/desk.png
node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/phone.png --width 390
node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/joint.png --zoom 200,80,220,170
node scripts/capture.mjs examples/ripple-tank/ripple-tank-light.html --out shots/still.png --reduced
```

**Drive.** Scripted sessions: move, click, key, wait for a phase, and contact sheets slowed down to catch pops in a shader. `--no-webgl` checks the SVG fallback.

```sh
node scripts/drive.mjs examples/raptor-engine/raptor-engine-light.html '[["wait",1500],["key","i"],["wait",3000],["shot","shots/firing.png"]]'
node scripts/drive.mjs examples/raptor-engine/raptor-engine.html '[["wait",1500],["shot","shots/no-webgl.png"]]' --no-webgl
node scripts/drive.mjs examples/raptor-engine/raptor-engine-light.html --actions ignite.json
```

Anything with `until` or `eval` goes in an actions file. This `ignite.json` waits for the engine to rest, then films the start of ignition at 20× slow motion:

```json
[
  ["key", "Escape"],
  ["until", "document.querySelector('[data-readout]').textContent.includes('cold')", 15000],
  ["slow", 0.05],
  ["key", "i"],
  ["sheet", "shots/ignite.png", 100, 16],
  ["slow", 1]
]
```

**3D figures.** The orbit audit is slow (budget 30 to 40 s per part for a full run), so iterate with `--quick` and run the full audit in the background. Only a full run's `turn audit passed` counts.

```sh
node examples/turning-dial/build.mjs --light --audit --quick
node examples/turning-dial/build.mjs --light --audit > audit.txt 2>&1 &
node scripts/turn-check.mjs examples/turning-dial/build.mjs --fidelity
node scripts/turn-check.mjs examples/turning-dial/turning-dial-verify-light.html --order --step 1
node scripts/turn-check.mjs examples/turning-dial/turning-dial-light.html --lines
node scripts/turn-check.mjs examples/turning-dial/turning-dial-light.html --perf --width 390 --throttle 4
node scripts/turn-bench.mjs examples/turning-dial/build.mjs
```

[`references/verify.md`](references/verify.md) has every option, the review checklist and the common faults. [`references/3d.md`](references/3d.md) covers the 3D checks.

## House rules

These come from [`SKILL.md`](SKILL.md) and [`references/`](references). A pull request that breaks one will be asked to change.

- **No comments in code.** Not a header, not a note beside a tricky line, not a JSDoc. Put the explanation in names and structure. Tool directives such as `"use client"` are not comments and stay.
- **Kit changes are opt-in.** A change to `kit/` or `scripts/inline-kit.mjs` adds an option or a function whose default is the old behaviour. Every existing example must rebuild byte-identical; CI checks it. If a change is meant to alter a figure, say so in the pull request and commit the rebuilt pages with it.
- **Both themes work, and light ships.** Every colour is a token with a light value and a dark override, never a literal in a figure's CSS or shader. Light shaders are designed for paper (normal premultiplied alpha, never `screen`), not dark glows darkened. Build with `--light` and check both pages.
- **No overlaps.** No pipe or cable passes through anything, at rest, apart or in any frame of a motion. Every crossing is drawn in true depth order, proved by the audit, never tuned by eye. Fix an interlock in the geometry, never with a key bias, and record the true shape of what you draw: a stand-in shape that quiets the audit is a bug.
- **Lines resolve and textures fade.** Every line ends on something. A rib row, bolt circle, seam or rule line ends on a real edge or fades out; it never stops in the middle of a surface. Curved surfaces shade smoothly, with no tone staircase.
- **Shaders never pop.** Every uniform rides an envelope with an attack and a decay, a phase change is a crossfade, and every term reaches zero before any bound or canvas edge. The effect lives in world space in the figure's own camera, and the SVG still reads without WebGL.
- **Every number is true.** The readout, the motion and the caption come from one model with the real numbers. Docs follow the same rule: never write a number or a feature you have not checked.
- **Read every PNG.** Look at 4× close-ups of every joint in both themes before you ask for a review.

## Changing the skill's text

`SKILL.md` and `references/` are instructions Claude follows, so keep them short, exact and in the same plain voice. When you change a kit API, update [`references/kit.md`](references/kit.md) in the same pull request. When you add an example, give it a folder under `examples/` with a `build.mjs` that writes `<name>.html` and, with `--light`, `<name>-light.html`; add it to the list in `SKILL.md`, the table in the README and the build list in [`.github/workflows/ci.yml`](.github/workflows/ci.yml).

## Pull requests

1. Open an issue first for anything larger than a fix, so we can agree on the approach.
2. Fork the repository and branch from `main`.
3. Make one change per pull request.
4. Rebuild every example and confirm that `git status --short` shows only what you meant to change.
5. Run the checks that apply, and attach screenshots in both themes for any visual change.
6. Add a line under `Unreleased` in [`CHANGELOG.md`](CHANGELOG.md).
7. Fill in the pull request template.

CI must pass before a merge.

## Reporting a bug or asking for a figure

Use the [issue forms](https://github.com/wheresryan22/anatomy/issues/new/choose). For a figure that came out wrong, include the prompt, the theme, where the figure lives (HTML, SVG or React), and a screenshot or close-up of the fault.

## LICENSE

```

```

## README.md

<h1 align="center">Anatomy</h1>

<p align="center"><b>A Claude skill that explains an idea by building it as a real, working object.</b><br>Crafted, interactive isometric figures with many small precise parts, in SVG, WebGL and 3D.</p>

<p align="center">
  <a href="https://code.claude.com/docs/en/skills"><img src="https://img.shields.io/badge/Claude_Code-skill-1b1b19?style=flat-square&logo=claude&logoColor=white" alt="Claude Code skill"></a>
  <a href="https://agentskills.io"><img src="https://img.shields.io/badge/Agent_Skills-compatible-1b1b19?style=flat-square" alt="Agent Skills compatible"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-1b1b19?style=flat-square" alt="MIT license"></a>
  <a href="#requirements"><img src="https://img.shields.io/badge/node-%E2%89%A5%2020.10-1b1b19?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node 20.10 or later"></a>
  <a href="#requirements"><img src="https://img.shields.io/badge/dependencies-0-1b1b19?style=flat-square" alt="No dependencies"></a>
  <a href="https://github.com/wheresryan22/anatomy/actions/workflows/ci.yml"><img src="https://github.com/wheresryan22/anatomy/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
</p>

<p align="center">
  <a href="https://skills.wheresryan.sh/anatomy"><img src="https://img.shields.io/badge/Website-skills.wheresryan.sh%2Fanatomy-1b1b19?style=for-the-badge" alt="Website: skills.wheresryan.sh/anatomy"></a>
  &nbsp;
  <a href="https://github.com/wheresryan22/anatomy/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/Download-ZIP-1b1b19?style=for-the-badge&logo=github&logoColor=white" alt="Download ZIP"></a>
  &nbsp;
  <a href="#install"><img src="https://img.shields.io/badge/Install-npx_skills_add-d7d7d0?style=for-the-badge&labelColor=1b1b19&logo=npm&logoColor=white" alt="Install with npx"></a>
</p>

<p align="center">
  <a href="#quick-start">Quick start</a> ·
  <a href="#gallery">Gallery</a> ·
  <a href="#what-it-makes">What it makes</a> ·
  <a href="#install">Install</a> ·
  <a href="#usage">Usage</a> ·
  <a href="#examples">Examples</a> ·
  <a href="#how-it-works">How it works</a> ·
  <a href="#limits">Limits</a>
</p>

<p align="center">
  <img src=".github/assets/raptor-engine-firing.webp" width="640" alt="An isometric drawing of a Raptor 2 rocket engine hanging in a test stand, firing: a red exhaust plume, drawn by a WebGL shader, pours onto the stand's base plate. The card reads Fig 1, Raptor 2 test stand, Pc 300 bar, 230 tf.">
</p>

<p align="center"><sub><code>examples/raptor-engine</code>, light theme, firing. The engine and stand are SVG; the plume is a WebGL shader in the same isometric camera.</sub></p>

---

Ask Claude for a figure that explains a technical idea, and Anatomy has it invent a physical thing whose working *is* the concept. A cache key that skips work when nothing changed becomes a pin-tumbler lock that only turns when every pin matches. One GPU context serving many canvases becomes a gantry plotter whose single print head visits 16 wells.

Then Claude builds that thing out of dozens of small, solid, shaded parts, puts it in a framed card with a title, a hint and a live readout, makes it move calmly under the pointer and the keyboard, and checks its own work by zooming in on every joint. Every number the figure shows comes from the same small model that moves its parts.

## Quick start

```sh
npx skills add wheresryan22/anatomy -g -a claude-code
```

Then, in Claude Code:

```text
/anatomy explain how a token bucket rate limiter works, interactive
```

That's it. No API keys and nothing to `npm install`: the kit uses only Node's built-in modules.

## Gallery

<table>
  <tr>
    <td width="50%"><img src=".github/assets/raptor-engine-apart.webp" alt="The Raptor 2 engine taken apart in its test stand: its assemblies, pipes and harness hang apart from each other between the posts of the stand."></td>
    <td width="50%"><img src=".github/assets/ripple-tank.webp" alt="A school ripple tank on four tall legs, with a lamp on an arm above it. Two dippers on a vibrating bar make interfering waves, and their caustics are projected onto the paper screen under the tank."></td>
  </tr>
  <tr>
    <td><sub><b>raptor-engine</b>: hover to take it apart into 11 assemblies. An audit proves that no pipe passes through anything, at rest, apart and in every frame between.</sub></td>
    <td><sub><b>ripple-tank</b>: SVG apparatus over two WebGL layers (lamp light and caustics on the paper, reflections in the water) driven by a dispersion and optics model.</sub></td>
  </tr>
  <tr>
    <td><img src=".github/assets/turning-dial.webp" alt="A combination dial on a raised door plate. A small gold and chrome robot hand lies on the dial and turns with it; a green light glows under one fingertip."></td>
    <td><img src=".github/assets/dial-indicator.webp" alt="A dial indicator gauge on a stand above a base plate with a ruler strip. Its plunger rests on one of three gauge blocks on a sliding carriage."></td>
  </tr>
  <tr>
    <td><sub><b>turning-dial</b>: the 3D mode (beta). About 90 parts are re-projected and re-shaded every frame as you drag the dial.</sub></td>
    <td><sub><b>dial-indicator</b>: the smallest example, about 300 lines. Hover to slide the carriage; the needle reads each block.</sub></td>
  </tr>
</table>

Every screenshot is the light theme of a page in [`examples/`](examples), captured with the skill's own [`scripts/capture.mjs`](scripts/capture.mjs) and [`scripts/drive.mjs`](scripts/drive.mjs). The built pages are committed: open any `.html` in `examples/` to try it.

## What it makes

Three kinds of figure. All three share one kit, one camera and one set of craft rules.

| Kind | What it is | Example |
|---|---|---|
| **SVG** | The core. Solid isometric parts with four-tone shading, painted back to front, in a card with a live readout and eased motion. It ships as a standalone HTML page, a lone `.svg` file with its styles embedded, or React/Next.js components. | `dial-indicator`, `desk-computer`, `arcade-cabinet`, `test-rig` |
| **SVG + WebGL** | For what lines can't draw: fire, exhaust, water, caustics, lamp light, glow, steam. The shader runs in a canvas between a back and a front SVG, in the figure's own isometric camera, so it sits inside the drawing. The SVG still reads without WebGL. | `raptor-engine`, `ripple-tank` |
| **3D** <sup>beta</sup> | Parts that turn in true 3D: a dial or turntable that spins, fingers that flex, a piece that breaks off and falls. The moving parts are re-projected and re-shaded by the kit's own rules every frame; the rest of the figure stays static art. | `turning-dial` |

Each figure is light or dark. Light is the theme the skill designs for first, shaders included.

## Install

Anatomy is a standard [Agent Skill](https://code.claude.com/docs/en/skills): a folder with a `SKILL.md` at its root. This repository is that folder. Pick one way in:

| | Command | Installs to |
|---|---|---|
| **skills CLI**, for you | `npx skills add wheresryan22/anatomy -g -a claude-code` | `~/.claude/skills/anatomy` |
| **skills CLI**, for one project | `npx skills add wheresryan22/anatomy` | `.claude/skills/anatomy` |
| **git**, for you | `git clone https://github.com/wheresryan22/anatomy.git ~/.claude/skills/anatomy` | `~/.claude/skills/anatomy` |
| **git**, for one project | `git clone https://github.com/wheresryan22/anatomy.git .claude/skills/anatomy` | `.claude/skills/anatomy` |
| **Download**, no git | see below | `~/.claude/skills/anatomy` |

<details>
<summary><b>With the skills CLI</b></summary>

<br>

The [`skills`](https://github.com/vercel-labs/skills) CLI finds the `SKILL.md` at the root of this repository and installs it as `anatomy`. By default it installs into the current project and asks which agents to set up. `-g` installs it for your user, and `-a claude-code` sets up Claude Code only.

The CLI copies the whole repository, examples included (about 10 MB). It needs a recent Node.js: version 1.7.1 of the CLI asks for 22.20 or later.

</details>

<details>
<summary><b>With git</b></summary>

<br>

Clone the repository into one of Claude Code's skill folders, so that `SKILL.md` ends up at `<skills folder>/anatomy/SKILL.md`. For a project install, commit `.claude/skills/anatomy` so your team gets it too. To update later, `git pull` inside that folder.

</details>

<details>
<summary><b>Download, without git</b></summary>

<br>

[Download the ZIP](https://github.com/wheresryan22/anatomy/archive/refs/heads/main.zip), unpack it, rename the folder from `anatomy-main` to `anatomy`, and move it into `~/.claude/skills/`.

Or in one line on macOS or Linux:

```sh
mkdir -p ~/.claude/skills/anatomy && curl -L https://github.com/wheresryan22/anatomy/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -C ~/.claude/skills/anatomy
```

</details>

Claude Code picks up new skills in a running session. If the `skills` folder itself did not exist when the session started, run `/reload-skills`.

## Usage

Type `/anatomy` and say what the figure should explain:

```text
/anatomy explain how a token bucket rate limiter works, interactive
```

You don't have to name the skill. Claude also loads it when you ask for an isometric illustration, an explanatory figure for docs, a blog post or a landing page, or an interactive explainer.

<details>
<summary><b>More prompts</b>, from the skill's <a href="evals/evals.json">evals</a></summary>

<br>

```text
/anatomy a figure for our Next.js docs on database connection pooling: a pool of 10 connections, requests borrow one and give it back, and when all 10 are busy new requests wait in a queue. A React component plus a static HTML preview, dark theme.
```

```text
/anatomy an isometric SVG explaining how a CDN edge cache works, cache hit vs miss, TTL of 60 s, for a dark landing page. Just the SVG file and a PNG preview.
```

```text
/anatomy a Bunsen burner on a lab bench: opening the air collar turns the lazy yellow flame into a roaring blue cone. WebGL for the flame, one HTML file, dark theme.
```

```text
/anatomy a heat-exchanger espresso machine with the side panel off: boiler, heat-exchanger tube, group head, pump, steam wand and gauge. Show the water flowing when you pull a shot. Standalone HTML, light theme.
```

</details>

Three habits get the best figures:

1. **Name the idea, not the drawing.** Say what it should explain and give the real numbers (sizes, counts, rates, thresholds). Anatomy invents the object; that is most of the work, and it does it better than a description of boxes and arrows.
2. **Or name the object.** If you already know the machine you want (an espresso machine, a Bunsen burner, a lock), say so, and say what should move.
3. **Say where it will live.** A standalone HTML page, a lone SVG file, or a React/Next.js component; light or dark. The build differs for each.

A figure arrives as source you own: a Node build script and the page it writes, or, for React, geometry modules, a server component for the static art and one client component for the motion. The kit comes with it.

## Examples

Each framework-free example has a Node build script that writes standalone pages next to itself. Add `--light` for the light theme.

| Example | What it shows | Build |
|---|---|---|
| [`dial-indicator`](examples/dial-indicator) | A dial gauge on a stand over a carriage of gauge blocks. The smallest example, about 300 lines. | `node examples/dial-indicator/build.mjs` |
| [`desk-computer`](examples/desk-computer) | A 1984-style desk computer with a keyboard you can type on. The typed line appears on a CRT drawn in the plane of the case front, and switching it off collapses the picture to a line and a dot. About 85 solids. | `node examples/desk-computer/build.mjs` |
| [`arcade-cabinet`](examples/arcade-cabinet) | An upright arcade cabinet with its side panel off. Drop a quarter: it rolls down the coin chute past the coin-switch wire into the cash box, a pulse runs along the cable to the board, and the CRT warms up into an attract mode drawn in the plane of the tilted monitor. About 240 solids and 15 cables. | `node examples/arcade-cabinet/build.mjs` |
| [`raptor-engine`](examples/raptor-engine) | A Raptor 2 engine in a test stand. It comes apart into 11 assemblies, and a WebGL plume, shock, splash and steam render between the back and front SVG. The reference for dense pipework, smooth shading, fades and the audit. The build takes a few minutes. | `node examples/raptor-engine/build.mjs` |
| [`ripple-tank`](examples/ripple-tank) | A school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers are registered to the isometric planes and driven by a pure model in `model.mjs`. | `node examples/ripple-tank/build.mjs` |
| [`test-rig`](examples/test-rig) | A production figure from a React/Next.js site: a glass button on a spring-mounted carriage, a finger probe on a rail, and a dial gauge reading the lean. 53 solids. No build script: copy it into a React app. | none |
| [`spot-plate`](examples/spot-plate) | A second figure from the same site: six pairs of glass drops on a spot plate that run together into one piece of glass as a fan of feeler blades opens. Each pair is drawn from one distance field. No build script: copy it into a React app. | none |
| [`turning-dial`](examples/turning-dial) | The 3D mode: a combination dial that turns in true 3D with a small robot hand on it, a three-hinge finger that lifts, and a pod that breaks off as a free body. `stress.mjs` is a 380-part hand in a cluttered workshop, used to measure performance. | `node examples/turning-dial/build.mjs` |

`node examples/raptor-engine/build.mjs --audit`, `node examples/arcade-cabinet/build.mjs --audit` and `node examples/turning-dial/build.mjs --light --audit` also run the geometry audit. The 3D audit is slow; see [`references/3d.md`](references/3d.md).

## How it works

- **[`SKILL.md`](SKILL.md)** is what Claude reads. It sets the workflow: collect the true numbers first, invent the object, write a parts list, plan the world, build the geometry once with the kit, paint back to front, frame it, make it live, and then verify it like a critic. It also holds the craft rules in short form.
- **[`kit/`](kit)** is the drawing library the figures are built from: the isometric camera, solids and a parts library (`iso-kit`), round parts on any axis (`lathe.mjs`), pipes and cables (`tube.mjs`), the WebGL layer (`gl.mjs`), the 3D runtime (`turn*.mjs`), and React components (`react/`). Apart from React for those components, it has no dependencies.
- **[`references/`](references)** are the long-form guides Claude opens when it needs them: craft, the kit API, motion, React, verification, WebGL, a design walkthrough and the 3D mode.
- **Verification.** [`kit/audit.mjs`](kit/audit.mjs) proves the geometry: nothing passes through anything, every end sits on a mount, and every overlap is drawn in depth order (`node build.mjs --audit`). In headless Chrome, [`scripts/capture.mjs`](scripts/capture.mjs) and [`scripts/drive.mjs`](scripts/drive.mjs) take screenshots, close-ups and slowed-down contact sheets, [`scripts/lines.mjs`](scripts/lines.mjs) fails any line that ends in mid-air, and [`scripts/turn-check.mjs`](scripts/turn-check.mjs) checks 3D figures. The skill tells Claude to read every PNG it makes.

<details>
<summary><b>Repository layout</b></summary>

<br>

```text
anatomy/
├── SKILL.md                  the skill: workflow, setup and craft rules
├── kit/                      the drawing library
│   ├── iso-kit.ts            the core: camera, solids, parts, SVG renderer, card and page, CSS
│   ├── iso-kit.mjs           the same core as plain ESM, for Node build scripts
│   ├── iso.css               the kit's CSS, both themes
│   ├── react/                draw.tsx (server-safe components), live.tsx (client hooks)
│   ├── lathe.mjs             round parts on any axis
│   ├── tube.mjs              pipes, tubes and cables
│   ├── audit.mjs             the geometry audit
│   ├── gl.mjs                the WebGL layer for shaders inside the drawing
│   ├── turn.mjs              the 3D runtime (beta)
│   ├── turn-build.mjs        the 3D builder
│   ├── turn-audit.mjs        the 3D orbit audit
│   └── turn-fixed.mjs, canvas-painter.mjs, gl-shared.mjs
│                             experimental variants, not yet documented or used by the examples
├── references/               long-form guides Claude reads as needed
│   ├── craft.md  kit.md  motion.md  react.md
│   └── verify.md  webgl.md  walkthrough.md  3d.md
├── scripts/                  headless Chrome checks and build helpers
│   ├── capture.mjs           screenshots and close-ups
│   ├── drive.mjs             scripted sessions and contact sheets
│   ├── lines.mjs             the line-end check (with line-ends.mjs)
│   ├── inline-kit.mjs        the kit as source, to inline into a standalone page
│   ├── turn-check.mjs        3D checks in the browser (with turn-fidelity.mjs)
│   └── turn-bench.mjs        3D timing in Node
├── examples/                 eight complete figures, built pages committed
├── evals/evals.json          test prompts and what a good answer contains
└── .github/                  issue forms, PR template, CI, README images
```

</details>

## Requirements

| | Needed for | Notes |
|---|---|---|
| **[Claude Code](https://code.claude.com/docs/en/overview)** | everything | The skill is written for it. |
| **Node.js 20.10+** | builds, kit, checks | Built-in modules only: nothing to `npm install`. The examples were rebuilt byte for byte on Node 20.19 and 22.11. On Node 20 the browser scripts restart themselves with `--experimental-websocket`. |
| **Chrome or Chromium** | the checks | `capture.mjs`, `drive.mjs`, `lines.mjs` and `turn-check.mjs` look in the usual places on macOS, Linux and Windows and in Playwright's cache. Set `CHROME_PATH` for any other binary. They run it headless with WebGL on. |
| **React 18+** | React output only | `kit/react/`, `examples/test-rig/` and `examples/spot-plate/`. |

## Limits

- **It is not a chart library.** There are no axes, series or data plots. Numbers appear as a live readout driven by a model, and as the true sizes and counts of the parts.
- **It draws machines, instruments, tools and furniture.** Not people or exact maps. An idea that has no mechanism to build will come out weaker.
- **It is slow on purpose.** A figure takes a long session: the parts list, the geometry, the audit, and close-ups of every joint in both themes. The Raptor's build takes a few minutes, and a full 3D orbit audit takes 15 to 20 minutes for 30 parts.
- **Shaders need WebGL.** Without it the SVG still reads, and under reduced motion every figure goes still.

> [!NOTE]
> **The 3D mode is beta.** It is proved on its example and one test figure, not yet on a production figure. A small 3D figure runs at 60 fps; the 380-part stress hand measured about 20 fps at 1440 px wide and 13 to 15 fps on a throttled phone profile, on a heavily loaded machine.

## Contributing

Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers building the examples, the checks, and the house rules a change has to keep (among them, no comments in code, and kit changes that leave every existing example byte-identical). Please follow the [Code of Conduct](CODE_OF_CONDUCT.md), and report security problems as described in [SECURITY.md](SECURITY.md).

- [Report a bug](https://github.com/wheresryan22/anatomy/issues/new?template=bug_report.yml)
- [Request a figure](https://github.com/wheresryan22/anatomy/issues/new?template=figure_request.yml)
- [Changelog](CHANGELOG.md)

## License

[MIT](LICENSE) © 2026 Ryan · [@wheresryan22](https://x.com/wheresryan22)

<p align="center"><sub>If Anatomy drew something useful for you, a ⭐ helps other people find it.</sub></p>

## SECURITY.md

# Security

## What is in scope

Anatomy is a set of instructions, a drawing kit and local Node scripts. The scripts that start Chrome (`capture.mjs`, `drive.mjs`, `lines.mjs`, `turn-check.mjs`) run it headless with a throwaway profile and `--allow-file-access-from-files`, and `drive.mjs` runs JavaScript you pass it in the page. Point them only at pages you trust.

A security problem is anything in this repository that could let a page, a prompt or a file run code, read files or reach the network in a way the user did not ask for: in the kit, in the scripts, in the built example pages, or in instructions in `SKILL.md` and `references/` that would lead Claude to do something unsafe.

## Supported versions

Only the latest commit on `main` is supported.

## Reporting a vulnerability

Please don't open a public issue for a security problem. Report it privately through GitHub's [private vulnerability reporting](https://github.com/wheresryan22/anatomy/security/advisories/new) for this repository. If that link does not work, send a direct message to [@wheresryan22](https://x.com/wheresryan22) on X asking for a private channel, without the details.

Include what you found, how to reproduce it, and what it could let someone do.

## SKILL.md

---
name: anatomy
description: Build crafted, highly detailed, interactive isometric SVG figures that explain a technical idea as a real physical object (a test rig, a dial gauge, a cabinet of drawers, a plotter, a lock), with many small precise solid parts, four-tone shading, correct painter's order, a framed card with title, hint and live readout, and calm eased motion. Use this whenever someone wants an isometric illustration, an explanatory diagram or figure for docs, a blog post, a landing page or an article, a "Linear-style" or "premium" technical drawing, an interactive explainer, or an SVG that shows how a system, algorithm or component works, even if they never say "isometric". Works framework-free (TypeScript/JS that writes SVG and HTML) and in React/Next.js, and can add realistic WebGL shaders (fire, exhaust, water, caustics, lamp light) that live inside the drawing in the same isometric perspective.
---

# Anatomy

This skill makes figures like a small instrument on a bench: a heavy base on feet, rails with tick rulers, a carriage on bushings, knurled knobs, coil springs, a dial gauge whose needle moves, screws as tiny dots. The reader understands the idea because they recognise the machine. Every number the figure shows comes from the same model that moves its parts.

Eight complete examples ship with the skill. Open the closest one before you start. Each framework-free example is built with `node examples/<name>/build.mjs`, which writes a standalone HTML page next to the script; add `--light` for the light theme.

- `examples/dial-indicator/` is the smallest: a dial gauge on a stand, about 300 lines in all.
- `examples/desk-computer/` is framework-free SVG: a desk computer with a keyboard you can type on. The typed line appears on a CRT screen drawn in the plane of the case front, and the picture collapses to a line and a dot when you switch it off. It has about 85 solids and 850 paths.
- `examples/arcade-cabinet/` is an upright arcade cabinet with its side panel off, a Hero of about 240 solids and 15 cables. A quarter flies into the slot, rolls down the coin chute at the speed a rolling disc reaches on a 30° slope, trips the coin-switch wire and drops into the cash box; the switch's pulse runs chunk by chunk along its cable to the board, and the CRT warms up, self-tests and runs an attract mode drawn in the plane of the tilted monitor. The stick tilts and closes a microswitch under the panel. It shows how to place a camera so a cutaway's mechanism stays in view, a concave board drawn as a prism along any axis, moving parts rebuilt in the page from the same module the build uses, and live coins and a wire drawn in reserved slots of the settled order. `node build.mjs --audit` proves the geometry.
- `examples/raptor-engine/` is a rocket engine hanging in a test stand. Its round parts come from `kit/lathe.mjs` and its pipes and harness from `kit/tube.mjs`. It comes apart into 11 assemblies with staggered springs and per-frame depth re-sorting. Every part records its true shape, and `node build.mjs --audit` (`kit/audit.mjs`) proves, at rest, apart and in every replayed frame of the explode, that nothing passes through anything, that every end sits on a real mount, that no bend pinches, folds or lands on another line, that no curved profile's slope wobbles, and that every overlap is drawn in depth order. A WebGL plume, shock, splash and steam render between the back and front SVG. It is the reference for dense pipework, smooth shading and fades.
- `examples/ripple-tank/` is a school ripple tank on tall legs over a paper screen, seen from 44° up. Two WebGL layers (lamp light, shadows and caustics on the paper, and the water's reflections and glass) are registered to the isometric planes and driven by a pure dispersion and optics model (`model.mjs`); a frequency change travels out from the dippers through a history texture.
- `examples/test-rig/` is a production figure from a real site (React/Next): a glass button on a spring-mounted carriage, a finger probe on a rail that presses and pulls, and a dial gauge reading the lean. It has 53 solids and about 470 paths.
- `examples/spot-plate/` is a second production figure from the same site: six pairs of glass drops on a spot plate, each pair drawn from one distance field, so a pair closer than `merge` runs together into one piece of glass. A fan of feeler blades sets `merge`, one blade per gap. It is the reference for shapes that morph into each other and for one control driving many parts.
- `examples/turning-dial/` is the optional 3D mode: a combination dial that turns in true 3D with a small robot hand on it. It has about 90 live parts in metal materials, a second turn group, a hinge, a three-hinge finger that lifts and a pod that breaks off as a free body. A WebGL LED, its light pool and sparks follow the moving parts. `stress.mjs` beside it is a dense hand of about 380 parts in a cluttered workshop, used to measure performance.

The test rig is the quality bar for the drawing itself. The Raptor is the bar for shader work: a WebGL effect has to reach its standard of world-space, no-pop, seamless rendering.

## The one idea that matters

**The object is the explanation.** Don't draw boxes, arrows and labels. Invent a real, well-made physical thing whose working *is* the concept. Here is how earlier figures mapped:

| Concept | Object |
|---|---|
| One shared GPU context serving many canvases | A gantry plotter: one print head visiting 16 wells |
| A cache key that skips work when nothing changed | A pin-tumbler lock: when every pin matches, the plug turns and nothing is redrawn |
| Seven render targets with different bytes per texel | A chest of seven drawers, each as deep as its bytes |
| A separable blur (49 × 49 taps vs 49 + 49) | A bed-of-nails stamp beside two combs on rails |
| A spring that presses and leans | A test rig with springs, a probe and a dial gauge |
| A height profile | A profile (contour) gauge pressed against the edge |
| Light that depends on facing | A small photo studio: key and fill lamps around a turntable |

Two ways this fails, both seen in practice:

- **Too abstract.** Stacked plates, floating slabs or labelled boxes don't read as anything.
- **Too flat and bland.** One big slab with a few lines on it has no volume, no parts and nothing to discover.

The fix for both is the same: a recognisable machine, built from many small, precise, purposeful parts.

**Every creation is highly detailed.** This holds for every figure, however small the idea or the request: a quick explainer is still a dense, real machine, never a few slabs. What scales with the size (workflow step 1) is how far you push it: a Figure gets the 4× sweep over its subject and mechanism, a Hero or an Epic over the whole drawing.
- **Read the real thing.** Build it the way the real object is built: every screw, seam, bevel, bushing, label plate, cable with its connector, scale with its ticks, and the parts that hold the parts.
- **Two looks.** At 1× it reads as one clear machine. At 4× every region still has something true to find; a bare face or an empty corner is unfinished work, not calm.
- **Purposeful, never noise.** Detail comes from parts that belong to the mechanism. Each one sits on another part, ends on an edge, and fades rather than stops.
- **Check it last.** Before you finish, zoom to 4× (over the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic) and add the real parts that are missing wherever a region is plain.

## Workflow

Work in this order. Write the concept and the parts list before any geometry. Most of the quality is decided there.

1. **Size it before you start.** Tell the user which size the request is and roughly how long it will take, before any other work.
   - **Figure**: one mechanism, 40–80 parts, SVG with at most one WebGL layer. About 30–90 minutes. Build it without asking.
   - **Hero**: a dense object, several WebGL layers or one 3D subject, 80–150 parts. About 2–4 hours. Say so, then build it unless the user asked for something quick.
   - **Epic**: a whole vehicle, building or plant with many moving systems, or the 3D mode on more than one subject. 6 hours or more. Do not start it. Give the estimate, propose a Hero version that keeps the core of the idea, and list what the full version would add. Build the Epic only when the user chooses it.
   - Whatever the size, have a working page that opens in the browser within the first hour, and deepen it from there. Run `date` at the start and at each step, and when a step runs past twice its share of the estimate, tell the user where it stands and what is left.
2. **Truth first.** Collect the real numbers and behaviour of the thing being explained: sizes, counts, thresholds, curves. The readout, motion and caption must all be true. If you can, write the model as a small pure module, like `rig.ts` in the test-rig example, and drive everything from it.
3. **Invent the object.** Pick a machine, tool, instrument or piece of furniture whose mechanism maps one-to-one onto the concept. Say in one sentence what moves and what that motion means. Never copy a reference image: take its finish, not its object.
4. **Parts list.** Write at least 40 parts (40–80 for a Figure, 80–150 for a Hero, more for an Epic), grouped: base (plate, feet, groove, corner screws, label plate, ruler strip), structure (posts, rails, frame bars, end stops), mechanism (carriage, bushings, springs, plunger, needle), controls (knurled knob, thumb screw, crown) and the subject itself, which is the one lit part. Each part sits on another part. `references/craft.md` has a vocabulary of parts with recipes.
5. **Plan the world.** Use world units of about 1 px of the real thing: x and y on the floor, z up. Lay out plans as rounded rectangles `{x, y, w, d, r}` and circles. Choose the camera: azimuth 45° by default, 35–60° to show the long side, elevation 30° (higher, like the ripple tank's 44°, to look into something open). Then fit with `fitProjection(points, width, height, { pad, azimuth })`.
6. **Build geometry once.** Precompute every static path in a geometry module with the kit: `slabOf`, `cylinder`, `extrude`, `planOutline`, `topTicks`, `knurl`, `coil`, `radialTicks` and so on. Round parts on any axis (bells, flanges, valves, actuators) come from `kit/lathe.mjs`, and pipes and cables from `kit/tube.mjs`. Moving parts are built once at a rest position and moved with `translateAlong`. Only parts whose shape changes are rebuilt per frame.
7. **Paint back to front, bottom to top.** Sort repeated parts with `depthOf`. Split anything that wraps another part into a back half and a front half: the springs around a rod, the bars of a frame around a carriage. A moving part is drawn in the slot where it physically sits. See "Painter's order" in `references/craft.md`. Once a figure has pipes, cables and round parts, stop tuning keys by eye: record each item's shape as you put it into `A.recorder(P, { order, spring })`, let `A.settle(R, P)` measure true depth wherever two items overlap and set the keys, and end the build with `A.auditOrExit(R, P)`. Route pipes before you draw them: real ports at both ends, legs along the structure, crossings on straights, collars gapping the pipe (`references/craft.md`, section 8). Record the true shape of what you draw: a stand-in shape that silences the audit is a bug, and the audit fails one. A rod that slides out of a gland is one rod from `G.rodOut`, recorded with `A.slide`.
8. **Frame it.** Put the figure in a card: `Fig n` at top left, a short title at top right, the interaction hint at bottom left and the live readout at bottom right. Legend keys and a true caption go under the card, and words stay out of the drawing apart from at most one tiny engraved label.
9. **Make it live.** Use calm time-based motion: exponential follow or real springs. An idle tour loops when nobody touches it. The figure responds to pointer and keyboard, stops when offscreen, and goes still under reduced motion. Write attributes on refs and don't re-render per frame. See `references/motion.md`.
10. **Verify like a critic.** The owner finds faults by zooming in, so you zoom in first. Read every PNG you make.
   - Capture desktop, phone (390 wide), hover and several moments of motion with `scripts/capture.mjs`; use `scripts/drive.mjs` for sequences, the apart pose and the `--no-webgl` fallback.
   - **Run the audit to zero.** With pipes, cables or round parts, `node build.mjs --audit` must end `audit passed`, every failing line at 0: clearance at rest, apart and in mid-flight, terminals, loose ends, folds, self-overlap, crowding (against slender solids too), stand-in shapes, support, wobble, saddles and depth order. Fix an interlock in the geometry, never with a key bias.
   - **Run the line check to zero.** The audit never sees line markup. `node scripts/lines.mjs page.html --out shots/lines.png` (and on the `-light` page, and in every resting pose with `drive.mjs`'s `["lines"]` action) must print `line ends: 0`: every seam, rule, rib, tick and pointer ends on a stroke, a dot or an outline. Read the ringed PNG.
   - **4× close-ups of every junction in both themes** (build `--light` too), assembled and apart: every flange, tee, elbow, clamp, valve, bellows, band, saddle, ball joint and crossing, every curved body (no tone staircase), every place a texture ends (on an edge or fading), flat box faces with their seams, every textured hose at a chunk joint, every sliding rod at rest and at full stroke, and every window with text or shader content in it.
   - **Shaders:** a contact sheet slowed with `["slow", 0.05]` across every transition; nothing pops, no colour switch, edges read 0. In light, also log ink per frame through each transition (it must follow the light monotonically) and crop where the ink meets strokes at 8–16× (`references/verify.md`, "Light-theme shaders").
   - Put your PNG next to the examples and ask: is it instantly a real object, rich in small parts with volume, with every line ending on something? Is it highly detailed at 1× and still rewarding at 4×, with no bare face or empty corner? Iterate until yes. Checklist: `references/verify.md`.

## Setup

The kit is in `kit/`:

| File | Use it for |
|---|---|
| `kit/iso-kit.ts` | The whole core in one file: camera, solids, a parts library, an SVG string renderer, card and page builders, and the CSS (`ISO_CSS`). Copy it into a TypeScript project. |
| `kit/iso-kit.mjs` | The same core compiled to plain ESM, for Node scripts. Import it directly. |
| `kit/iso.css` | `ISO_CSS` written out. Import it once and put `class="iso"` (plus `data-theme="light"` for light) on a wrapper or `<body>`. |
| `kit/react/draw.tsx` | Server-safe React components: `Solid`, `Line`, `Wire`, `Dots`, `FaceText`, `IsoFigure`, `Plate`. |
| `kit/react/live.tsx` | `"use client"` hooks: `useReadout`, `useLoop`, `useEased`, `useInView`, `useStillness`, plus `spring` and `follow`. |
| `kit/lathe.mjs` | Round parts along any axis: `frameAlong`, `lathe` (smooth shading switches on by itself for any profile with a smooth knot), `solidOf`, `disc`, `ringBand`, `sphereOf`, `saddleOf`, `bandsOf` (one curved body cut into depth-sortable bands that share one surface, with one outline from `sidesOf`), visible arcs, ribs and bolt circles on round surfaces (`ribsOf` and `dotsOf` take `fade`, `ribsOf` takes `seams`), `rodOut` for a rod that slides out of a gland, and `fillet` for pipe routes. |
| `kit/tube.mjs` | `tubePieces` splits a 3D pipe or cable into depth-sortable chunks (with `breaks`, `gaps` under collars and clamps, elliptical end caps, and `rings` for a braid, corrugation or wrap that follows the cross-section through every joint), and `tubeSvg` draws one; `crossingsOf` finds where a route passes through a clamp, `runsOf` the drawn runs. Its `.tb-*` fills are in `ISO_CSS`. |
| `kit/audit.mjs` | Proves the geometry. `recorder(P, { order, spring })` collects items, shapes (turned bodies, slabs, prisms, boxes, balls, tubes, pads cut by their host) and routes as the helpers draw them; `settle(R, P)` ray-casts every overlapping pair for true depth, at rest, apart and in the replayed explode, and sets the painter's keys (`overlapsOf`, `orderKeys`); `auditOrExit(R, P)` is the `--audit` hook that runs every check (`clearances`, `solidClearances`, `terminals`, `looseEnds`, `supports`, `folds`, `selfOverlaps`, `crowding` with slender solids as lines, `coverage` for stand-in shapes, `wobble`, `seats`, `depthOrder`) and exits non-zero on any problem. `A.slide` records a rod that slides out of a gland. `references/kit.md`, section 12. |
| `kit/gl.mjs` | The WebGL layer for shaders inside the drawing: `GLSL_ISO` (the camera in GLSL, including `rayOf` and `pixelOf`), `glCamera(P)`, `glLayer(...)`, and the JS envelopes `smooth`, `burst` and `settle`. For 3D figures, `GLSL_TURN` and `glslPose(name)` bring world points into a moving group and mask a front canvas with `coverOf`. |
| `kit/turn.mjs`, `kit/turn-build.mjs`, `kit/turn-audit.mjs` | The optional 3D mode. `turn-build.mjs` has `turning(P, { recorder })`: groups (turn, slide, hinge, free), live layers, convex parts with materials, and `T.build`. `turn.mjs` is the runtime: `TURN.mount`, the controller, `TURN.cover`, `TURN.poseUniforms` and `TURN_CSS`. `turn-audit.mjs` has `orbitOrExit`, the orbit audit over every turn angle and motion clip. `references/3d.md`. |
| `scripts/capture.mjs` | Headless Chrome screenshots, close-ups and console checks, with WebGL on. Run with `node`. |
| `scripts/drive.mjs` | Scripted headless Chrome with WebGL: move, click, key, slow motion, wait-until, eval, shots and contact sheets that catch pops; `--no-webgl` and `--preload`. |
| `scripts/lines.mjs` | The line-end check on a built page, in headless Chrome: every texture line must end on a stroke, a dot or an outline. Rings the failures with `--out`; exits 1 on any. |
| `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as source to inline into a standalone page. |
| `scripts/turn-check.mjs`, `scripts/turn-bench.mjs` | 3D figures: fidelity against the kit builders, a z-buffer order check, pixel pops, line ends at 24 angles and frame timing in the browser; Node CPU time per part kind. |

Pick the path that fits the project:

- **Standalone HTML, a static SVG, a blog post or any non-React site.** Write a Node build script like `examples/dial-indicator/build.mjs`. It imports `kit/iso-kit.mjs`, assembles strings with `solidSvg`, `lineSvg`, `dotsSvg`, `wireSvg` and `faceTextSvg`, wraps them with `figureSvg` and `plateHtml`, and writes the page with `pageHtml({ script: kitScript() + live })`, or `kitScript() + glScript() + live` when it has a shader. For a lone `.svg` file use `figureSvg({ standalone: "dark" | "light" })`, which embeds the styles.
- **React or Next.js.** Copy `kit/` into the project (for example `components/iso/`). Keep geometry in plain `.ts` modules, build the static art in a server component with the `draw.tsx` components, and pass it as element props to one `"use client"` live component that moves things. `references/react.md` explains the split and a trap with large pages.

## Craft rules (short form)

These are the rules that separate a crafted figure from a diagram. `references/craft.md` gives the full reasoning and recipes.

- **Solids, not wireframes.** Every part is filled in paper colour and painted in order, so things in front hide things behind. No see-through edges and no dashed hidden lines.
- **Let the kit shade.** `extrude` returns four side tones by facing plus a lighter top. Keep it for anything with height, and use `flat` only for floors and inlays thinner than 1 unit.
- **Tone hierarchy.** Base and feet are `lo`, structure is `mid` and the subject is `hi`. Exactly one accent is `lit` (white in dark, black in light): the part the reader should watch. Grooves are `faint`, minor ticks `lo`, major ticks `mid` and the zero mark `hi`. Use colour only when colour is the subject, such as red, green and blue channels.
- **Small precise detail, never noise.** Use bevels of 0.4–1.6, grooves inset 3–5 from an edge, screws as 0.4–0.55 dots with a ring at the corners of plates, knurls on knobs, ruler ticks every 4–5 units with a major every 5th, and a label plate with three rule lines. Each detail belongs to a part.
- **Lines resolve.** Every line ends on something: a face, another line or a terminal dot. Connectors are `Wire`s whose terminal dots land on objects. There are no leader lines, balloons, dimension arrows or labels floating in the air.
- **No pipe or cable passes through anything.** Not through another pipe, cable or solid, at rest, apart or in any frame of the explode. Keep about 1.5 radii of clearance to everything but a line's own connection, run lines beside the structure rather than across the subject's face, and gap a pipe wherever a flange, band, bellows, clamp or valve wraps it. Parts meet flush, tangent or rim-on-surface, never sunk in: a flange's back face lands on its port (on a saddle pad, `G.saddleOf`, when the port is curved), a branch leaves from a ball tee, and every end, apart too, finishes on a flange, coupling, connector shell or terminal dot.
- **Every crossing is drawn in true depth order.** Where two items overlap on screen, the nearer is drawn on top, proved by `A.settle` and the audit, not tuned by eye. Lines cross on straights, well apart in depth: a bend, end or clamp that lands on another line's outline reads as a joint (a rod, tie rod, post or rail counts as a line: a pipe's last leg never runs along one on screen), a leg that points at the viewer and then turns pinches to a V (leave such a port through an elbow ball), and a route never doubles back on screen. A pair that can't be ordered interlocks, and the fix is a cut or a tangent contact, never a bias.
- **Curved surfaces shade smoothly, never in a staircase.** Mark curved knots smooth (`[s, r, 1]`) and `lathe` shades them from one monotone curve that also draws the outline; give any curved body larger than a fitting an analytic profile with `smooth: { slope }`, cut long bodies with `G.bandsOf`, and use `G.sphereOf` for balls. Tone bands taper to nothing instead of ending on a straight cut, and seams go under the outline (`inner`).
- **A texture ends on a real edge or fades.** Ribs, bolt circles, rule lines, seams and grooves never stop in the middle of a surface, flat or curved: `ribsOf` and `dotsOf` take `fade` (draw faded ribs with `fadedSvg`), a rib row ends on its seams (`ribsOf(…, { seams: true })`), a seam or weld on a box wraps its corners (`k.sideSeam`, as the slab's `inner`), a line on one face runs edge to edge, vents are closed slots, ticks hang from a ruler strip's edge, a single line that must stop ramps out with `fadeLineSvg`, and a row whose features face up (holes on top of a ring) is seen all round (`all: true`). A texture on a pipe (braid, corrugation, wrap) follows its cross-section and runs through every chunk joint: `tubePieces(…, { rings })`, never a dashed stroke down the centreline. A pipe's shine and shade run onto its end caps and through every chunk joint. Only rule lines that stand for printed text, a needle's tail and a pointer's tip may stop on a bare face, marked `free`.
- **Shaders never pop or cut off.** Every uniform rides an envelope with an attack and a decay, phase changes are crossfades driven by the physical quantity, every term reaches zero before any bound or canvas edge, and the effect lives in world space in the figure's own camera (see below).
- **Things on faces fit them.** Text a machine displays fits its window at its widest string and is clipped to it; content seen through an opening is clipped by the opening as well as by its own plane, with the recess walls drawn.
- **Volume.** Give things real thickness, make them stand on other things, and put a soft halo under the base and under anything lifted.
- **Composition.** Centre the figure and let it fill about 55–65% of the card. Keep a consistent scale across a set of figures, and make sure it still reads at 390 px wide.

## WebGL inside the drawing

Use a shader only for what lines can't draw: fire and exhaust, water and caustics, real lamp light, glow, steam. The parts stay SVG. The core rules come from the first shader figure, whose colour change between phases looked cut rather than crossfaded:

- **Three layers.** A back SVG holds what the effect covers, the canvas holds the effect (`pointer-events: none`), and a front SVG holds what covers it. Split a part, or the effect, when it is on both sides.
- **World space, same camera.** Build every shader point from `onFloor`, `onWallX` and `onWallY`, or march the pixel's ray `rayOf(vb) + t·towardViewer()`. Size everything in world units times `uK`. Anything lying on a plane registers exactly with that plane's SVG lines.
- **Nothing pops.** Every uniform follows an envelope with an attack and a decay (the kit's `burst` for events, `settle` for levels). Phase changes are crossfades where colour shifts continuously with the physical quantity, and a fade-out only gets darker.
- **No hard cut-offs.** Every term reaches zero before any `if`, bound or canvas edge, and before any cell edge of a cell noise.
- **Seen through an opening, masked twice.** Content behind a window or a recess is masked in its own plane and by the opening in the face plane, multiplied (`references/webgl.md`, section 5).
- **Blending.** Use `tonemap()` and dither. In dark, a canvas that only adds light uses premultiplied output with `mix-blend-mode: screen`, and one that must also darken (the ripple tank's water) stays normal-blended.
- **Light theme ships, and its shaders are designed for paper.** Use normal premultiplied alpha, never screen. Write the colour the paper should show and solve for the ink. A glow is a saturated body with a pale core, a coloured rim and an alpha floor, never a darkened dark-theme colour: that gave the Raptor's "red ink smoke".
  - What the light falls on takes a tint, and the cavity that holds it gets a darker physical ground (soot, refractory, water depth).
  - Hot bodies follow one blackbody ink table shared with the fallback.
  - Ink only grows with light: fade the finished ink once, never one term at a time.
  - Ink ends at each stroke's inner edge.
  - Measure ink per frame. `references/webgl.md`, section 8, has the recipes and the faults reviewers found.
- **Restraint.** Cap the device ratio at 1.5 and render only while the effect is active and onscreen. Reduced motion gets one still frame, and the SVG must still read without WebGL.

Read `references/webgl.md` before writing any shader.

## 3D: figures that turn (beta)

3D is an option like WebGL. Use it when a part moving in depth is the idea: a dial or turntable that turns, fingers that flex, a piece that breaks off and falls. The rest of the figure stays static kit art.

It is in beta: the engine is proved on its example and one test figure, not yet on a production figure. Turn one subject at a time and keep the clutter around it static. A small figure runs at 60 fps; a dense 380-part flexing hand measured about 20 fps at 1440 and 13–15 fps on a phone profile (390 px, 4× CPU throttle) on a heavily loaded machine.

- **Live parts are the kit's own picture.** Each frame, convex kit primitives (prisms, rounds, domes, balls with flats, lathes, tubes) are re-projected and re-shaded by the kit's rules. A turn by θ is the kit drawing at azimuth `a − θ`.
- **Order comes from separating planes.** Same-group pairs get a plane fixed in the group, pairs on different links get one fixed in their common frame, and the rest are solved each frame. The audit proves there are no cycles at any angle or in any clip frame.
- **Contacts are flush, tangent or rim-on-flat.** Joints are coaxial knuckle discs on the child group that touch the parent's link, or the audit calls the whole child group loose. No live line spans two moving groups: a cable crosses a joint through a clip on each side, or reaches the turntable through a slip ring at the hub.
- **Live tubes bend in near-horizontal planes or inside fittings, and never point along the view.** A bend needs R ≥ 2r / sin²(e − θmax), where θmax includes the pitch range of every hinge it rides: 8r is the bound when flat, and on a ±12° hinge it is 21r. Each route runs from mount surface to mount surface, with its ports in `gaps`. That holds at every angle and in every pose.
- **Materials remap the ramp.** Gold, chrome, steel, gunmetal, rubber, brass and copper each have a light and a dark set; tones still come from facing.
- **Free groups break off without a jump.** Coaxial parts are fixed pictures with turning textures.
- **Shaders follow the motion.** Anchor them to a group with `controller.anchor` or `toGroup`, occlude a front canvas with `coverOf`, and keep world effects (smoke, falling sparks, oil on the bench) out of group space.
- **Verify with `--audit`** (`turn audit passed`) and `scripts/turn-check.mjs` (`--fidelity`, `--order`, `--pops`, `--lines`, `--perf`). Iterate with `--quick --only=…`. Only a full run proves the figure, and it takes 15–20 minutes for 30 parts, so run it in the background.

Read `references/3d.md` first.

## References

Read these as you need them:

- `references/craft.md`: the look in depth, including the parts vocabulary with code, tone tables, painter's order patterns, round parts, pipes and harnesses, exploded views, screens on faces, and the anti-patterns that got figures rejected. Read it before designing.
- `references/kit.md`: the API, with signatures and examples.
- `references/motion.md`: live behaviour (loops, springs, tours, pointer, keyboard, accessibility, reduced motion), plus envelopes for one-shot events, exploded views, depth re-sorting and arming, in vanilla JS and React.
- `references/react.md`: the React/Next.js server/client split, element props and the large-payload key-warning trap.
- `references/verify.md`: the `capture.mjs` and `drive.mjs` commands, the review checklist (including shaders) and common faults.
- `references/webgl.md`: shaders inside the drawing: when one earns its place, the layer stack, the camera contract, ray-marching in the iso camera, the full API with a minimal page, the craft rules with the bugs that taught them, the light theme (inks on paper, per-phenomenon recipes, the reviewers' faults), and how to verify them.
- `references/walkthrough.md`: how the test rig was designed, step by step, from concept to code.
- `references/3d.md`: the optional 3D mode: groups and poses, live layers, convex parts and their rules, materials, free groups, hinge chains, painter's order, static clutter around a turntable, shaders that follow the turn, the orbit audit, browser checks, performance and limits.

## evals

```

```

## evals/evals.json

```json
{
  "skill_name": "anatomy",
  "evals": [
    {
      "id": 1,
      "prompt": "I'm writing a blog post about how a token-bucket rate limiter works. Can you make me one interactive isometric illustration for it, the polished kind (like Linear's site), as a single HTML file I can just open? My blog has a white background. The bucket holds 20 tokens, refills 5 per second, and each request takes one token; requests get rejected when it's empty.",
      "expected_output": "A single standalone HTML file, light theme, with one framed isometric figure of a real physical object that explains a token bucket (tokens refilling, requests taking tokens, rejection when empty), live readout with true numbers, calm eased motion, keyboard/pointer interaction. Every curved part (a hopper, a drum, a funnel) is smooth-shaded with no stepped tone blocks, every row of ticks, ribs or bolt dots ends on a real edge or fades out instead of stopping mid-surface, and nothing passes through anything; any pipe or chute is recorded and `node build.mjs --audit` ends `audit passed` with zero on every failing line. It was checked with 4x close-ups of every joint, and every PNG was looked at.",
      "files": []
    },
    {
      "id": 2,
      "prompt": "We need a figure for our Next.js docs explaining database connection pooling: a pool of 10 connections, requests borrow a connection and give it back, and when all 10 are busy new requests wait in a queue. Build it as a React component (TSX) we can drop into the app, and also give me a static HTML preview so I can look at it right now. Dark theme. It should look like a crafted physical object with fine details, not a boxes-and-arrows diagram.",
      "expected_output": "A React TSX component (plus any geometry/CSS files) and a standalone HTML preview, dark theme, showing connection pooling as a real object with many small parts, live readout, painter's order correct, motion/interaction. Lines, hoses or cables that represent connections run along the structure in lanes and land on real ports, never through another part or across the subject's face; where two cross on screen the nearer is drawn on top. If the figure has tubes or round parts, their shapes are recorded with kit/audit.mjs and the preview's build reports zero audit problems. Curved parts show no tone staircase and no texture stops abruptly. 4x close-ups of the junctions were taken and read.",
      "files": []
    },
    {
      "id": 3,
      "prompt": "make an isometric svg explaining how a CDN edge cache works (cache hit vs miss, TTL of 60s) for our landing page. dark, premium, lots of small detail. just give me the svg file, and a png preview if you can",
      "expected_output": "A standalone dark SVG file (styles embedded) of a crafted isometric object explaining a CDN edge cache (hit vs miss, TTL 60 s), rich in small parts, monochrome with one accent, no floating labels. No line passes through a part, every crossing is in depth order, curved surfaces are smooth-shaded without a staircase, and every texture (vents, ticks, bolt circles) ends on an edge or fades. A PNG preview plus 4x close-ups were made and looked at.",
      "files": []
    },
    {
      "id": 4,
      "prompt": "For a chemistry explainer, make an interactive isometric figure of a Bunsen burner on a lab bench, showing how opening the air collar turns the lazy yellow flame into a roaring blue cone. I want the flame to look real, so use a WebGL shader for it, but keep the crafted line-drawing look for the burner itself. One HTML file I can open, dark theme.",
      "expected_output": "A single standalone dark HTML page built with the kit. The burner is SVG with many small parts made with kit/lathe.mjs: a weighted base, a barrel, a rotating air collar with real air holes, a jet and a ribbed gas inlet with a hose from kit/tube.mjs, on a bench plate, with an optional tripod and gauze. Every curved profile (base dome, barrel flare, inlet) is smooth-shaded with no tone staircase, from smooth knots or an analytic profile with smooth.slope; the inlet ribs and any bolt circle fade toward the limb (ribsOf/dotsOf with fade) instead of stopping. The hose leaves the inlet along its axis, lies on the bench without passing through the base, tripod or bench, and is gapped under any clamp or band; all shapes are recorded with kit/audit.mjs and `node build.mjs --audit` ends `audit passed`, zero on every failing line. The flame is a WebGL shader on glLayer (glScript inlined), in a canvas between a back SVG (bench, base, everything the flame covers) and a front SVG (whatever passes in front of it, such as the near tripod leg or gauze). The canvas has pointer-events none and blends with screen and premultiplied output. The flame lives in world space: it is ray-marched along towardViewer() around the barrel axis, sized in world units, and its base registers with the barrel rim from the same numbers as the SVG. Its colour and shape follow the air-to-gas ratio from a small model continuously (a luminous yellow sooty flame turning into a blue inner cone and pale outer mantle) through eased envelopes, with no instant colour switch. Lighting and turning off are attack-and-decay envelopes built on the kit's burst and settle (inlined by glScript, not redeclared), with constant geometry passed through glLayer's fixed uniforms. No term is cut at a bound or at the canvas edge, and the output is tonemapped and dithered. The device ratio is capped at 1.5, the shader renders only while lit and onscreen, reduced motion shows one still flame, and the SVG reads without WebGL. The live readout (collar opening, air/gas ratio, flame temperature) comes from the model, with pointer and keyboard control and an idle tour. The figure was checked with drive.mjs contact sheets slowed with [\"slow\", 0.05] across lighting, a full collar sweep and turning off, plus 2x and 4x crops at the barrel rim and 4x close-ups of the hose, inlet and collar in both themes, a --no-webgl run showing the SVG burner alone, and a --reduced run.",
      "files": []
    },
    {
      "id": 5,
      "prompt": "For an article on how espresso machines work, make an interactive isometric figure of a heat-exchanger espresso machine with the side panel off: boiler, heat-exchanger tube, group head, pump, steam wand, pressure gauge and all the pipework between them. Show water flowing through when you pull a shot. Lots of fine detail, it should hold up when people zoom in. Standalone HTML, light theme.",
      "expected_output": "A single standalone light HTML page built with the kit: a real espresso machine with its side panel off, its boiler, group head, gauge and fittings turned with kit/lathe.mjs and its pipework from kit/tube.mjs, with a live readout from a small model (boiler pressure, brew temperature, flow) and a calm pull-a-shot interaction. Every pipe is routed from real ports along the structure (leaving each port on its normal, running parallel to the boiler or round it at a fixed radius, crossing other lines only on straights and well apart in depth), never across the front of the boiler or group; it ends on a flange, union nut or fitting on a real port, never in the air. Nothing is sunk into anything: flanges sit flush or on saddle pads on the curved boiler, branches leave from ball tees, valves sit on straight runs, and every collar, clamp, band or valve gaps the pipe it wraps. Every item records its shape in A.recorder, A.settle sets the painter's keys, and `node build.mjs --audit` ends `audit passed`, zero on every failing line, with no interlock hidden by a key bias. The boiler, group head dome and gauge are smooth-shaded with no tone staircase or scalloped bands (analytic profiles with smooth.slope or bandsOf for the boiler), seams sit under the outline, and every bolt circle, rib row or rule line ends on a real edge or fades out (dotsOf/ribsOf with fade, fadeLineSvg). Pipe stripes run onto their end caps with no hairline at chunk joints. The response shows that 4x close-ups of every junction (each tee, flange, union, valve, clamp and crossing) and every curved body were taken in both themes and read, and that the water-flow motion has no pop.",
      "files": []
    }
  ]
}
```

## examples

```

```

## examples/arcade-cabinet

```

```

## examples/arcade-cabinet/arcade-cabinet-light.html

```

```

## examples/arcade-cabinet/arcade-cabinet.css

```

```

## examples/arcade-cabinet/arcade-cabinet.html

```

```

## examples/arcade-cabinet/build.mjs

```js
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import * as G from "../../kit/lathe.mjs";
import { tubePieces, tubeSvg } from "../../kit/tube.mjs";
import * as A from "../../kit/audit.mjs";
import { kitScript } from "../../scripts/inline-kit.mjs";
import { makeParts } from "./parts.mjs";
import { SCREEN } from "./screen.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const THEME = process.argv.includes("--light") ? "light" : "dark";

const W = 600;
const H = 760;
const AZIMUTH = 146;
const ELEVATION = 24;

const PLATE = { x: -98, y: -18, w: 184, d: 236, r: 10 };
const PLATE_H = 10;
const FOOT_H = 5;
const XI = 60;
const XW = 64;
const TOPZ = 358;

const P = k.fitProjection(
  [...k.boxCorners(PLATE, -PLATE_H - FOOT_H, 0), [-XW, 0, TOPZ], [XW, 0, TOPZ], [XW, 126, TOPZ], [-XW, 126, TOPZ], [-XW, 178, 184], [XW, 178, 184]],
  W,
  H,
  { pad: 30, azimuth: AZIMUTH, elevation: ELEVATION },
);
const V = G.viewOf(P);
const KS = G.scaleOf(P);
const at = (p) => k.iso(p, P);
const S = (paths, style) => k.solidSvg(paths, style);
const Ln = (d, style) => k.lineSvg(d, style);
const Dots = (points, style) => k.dotsSvg(points, style);
const Faded = (list, style) => k.fadedSvg(list, style);
const path3 = (points, closed = false) => k.pathOf(points.map(at), closed);
const f2 = (v) => Math.round(v * 100) / 100;

const R = A.recorder(P);
const record = R.solid;
function put(name, ref, svg, shapes = [], { bias = 0, ...rest } = {}) {
  return R.put({ name, at: ref, bias, svg, shapes, piece: "cab", ...rest });
}

const signed = (poly) => poly.reduce((a, [x, y], i) => {
  const [x2, y2] = poly[(i + 1) % poly.length];
  return a + x * y2 - x2 * y;
}, 0);
const screenOriented = (pts) => (signed(pts) < 0 ? [...pts].reverse() : pts);
function runsOf(flags, want) {
  const count = flags.length;
  const runs = [];
  const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
  if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
  let run = null;
  for (let step = 0; step < count; step++) {
    const index = (start + step) % count;
    if (flags[index] === want) {
      if (!run) run = [index];
      run.push((index + 1) % count);
    } else if (run) {
      runs.push(run);
      run = null;
    }
  }
  if (run) runs.push(run);
  return runs;
}

function prismPaths(F, poly, s0, s1) {
  const ring = signed(poly) > 0 ? poly : [...poly].reverse();
  const n = ring.length;
  const W3 = (s, [x, y]) => [F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y, F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y, F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y];
  const capNear = G.dot3(F.a, V) > 0;
  const [nearS, farS] = capNear ? [s1, s0] : [s0, s1];
  const near = ring.map((p) => at(W3(nearS, p)));
  const far = ring.map((p) => at(W3(farS, p)));
  const normals = ring.map(([x0, y0], i) => {
    const [x1, y1] = ring[(i + 1) % n];
    return G.unit3(G.add3(G.mul3(F.u, y1 - y0), G.mul3(F.v, -(x1 - x0))));
  });
  const facing = normals.map((m) => G.dot3(m, V) > 1e-9);
  const outline = [];
  const crease = [];
  for (const run of runsOf(facing, false)) outline.push(k.pathOf(run.map((i) => near[i])));
  for (const run of runsOf(facing, true)) {
    crease.push(k.pathOf(run.map((i) => near[i])));
    outline.push(k.pathOf(run.map((i) => far[i])));
  }
  for (let i = 0; i < n; i++) if (facing[(i + n - 1) % n] !== facing[i]) outline.push(k.pathOf([near[i], far[i]]));
  const parts = [k.pathOf(screenOriented(near), true), k.pathOf(screenOriented(far), true)];
  const buckets = [[], [], [], [], []];
  for (let i = 0; i < n; i++) {
    if (!facing[i]) continue;
    const quad = screenOriented([near[i], near[(i + 1) % n], far[(i + 1) % n], far[i]]);
    parts.push(k.pathOf(quad, true));
    buckets[G.toneOf(normals[i], P)].push(k.pathOf(quad, true));
  }
  const capTone = G.toneOf(capNear ? F.a : G.mul3(F.a, -1), P);
  buckets[capTone].push(k.pathOf(screenOriented(near), true));
  return { fill: parts.join(""), outline: outline.join(""), crease: crease.join(""), top: buckets[4].join(""), shades: buckets.slice(0, 4).map((b) => b.join("")) };
}

const XF = G.frameOf([0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]);
function board(name, poly, x0, x1, style = {}, { extra = "", inner = "", bias = 0, holes = [], ref } = {}) {
  const shape = record(A.prism(XF, poly, x0, x1, { name }));
  const cy = poly.reduce((a, p) => a + p[0], 0) / poly.length;
  const cz = poly.reduce((a, p) => a + p[1], 0) / poly.length;
  const dots = holes.length ? Dots(holes.map(([y, z]) => at([x0, y, z])), { size: 0.5, tone: "lo" }) : "";
  put(name, ref ?? [(x0 + x1) / 2, cy, cz], S(prismPaths(XF, poly, x0, x1), { tone: "mid", ...style, inner }) + dots + extra, [shape], { bias });
  return shape;
}
function prism(name, F, poly, s0, s1, style = {}, { extra = "", inner = "", bias = 0, ref } = {}) {
  const shape = record(A.prism(F, poly, s0, s1, { name }));
  const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length;
  const cy = poly.reduce((a, p) => a + p[1], 0) / poly.length;
  const centre = G.add3(G.add3(G.add3(F.o, G.mul3(F.a, (s0 + s1) / 2)), G.mul3(F.u, cx)), G.mul3(F.v, cy));
  put(name, ref ?? centre, S(prismPaths(F, poly, s0, s1), { tone: "mid", ...style, inner }) + extra, [shape], { bias });
  return shape;
}
const rect = (u0, u1, v0, v1) => [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
function box(name, [x0, y0, z0], [x1, y1, z1], style = {}, { r = 0.4, steps = 2, bevel = 0, extra = "", inner = "", bias = 0 } = {}) {
  const plan = { x: x0, y: y0, w: x1 - x0, d: y1 - y0, r };
  const shape = record(A.slab(plan, z0, z1 - z0, { name }, steps));
  put(name, [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2], S(k.slabOf(plan, z0, z1 - z0, P, steps, bevel), { tone: "mid", ...style, inner }) + extra, [shape], { bias });
  return shape;
}
function turned(name, F, profile, style = {}, { extra = "", inner = "", bias = 0, smooth, steps, rims, bevel = 0, owner } = {}) {
  const opts = { bevel, ...(smooth !== undefined ? { smooth } : {}), ...(steps ? { steps } : {}), ...(rims ? { rims } : {}) };
  const paths = G.solidOf(profile, F, P, opts);
  const mid = (profile[0][0] + profile[profile.length - 1][0]) / 2;
  const shape = record(A.solid(F, profile, { name, owner, radius: smooth && smooth.radius ? smooth.radius : null }));
  put(name, G.add3(F.o, G.mul3(F.a, mid)), S(paths, { tone: "mid", ...style, inner }) + extra, [shape], { bias });
  return shape;
}
function disc(name, F, s0, s1, r, style = {}, options = {}) {
  if (options.owner === undefined && name.startsWith("crt.")) options = { ...options, owner: "crt" };
  return turned(name, F, [[s0, r], [s1, r]], style, options);
}
function ball(name, c, r, style = {}, { bias = 0, extra = "" } = {}) {
  const shape = record(A.ball(c, r, { name }));
  put(name, c, S(G.sphereOf(c, r, P, { steps: 32 }), { tone: "mid", ...style }) + extra, [shape], { bias });
  return shape;
}
function planeMatrix(o, ex, ey) {
  const p0 = at(o);
  const px = at(G.add3(o, ex));
  const py = at(G.add3(o, ey));
  const v = (x) => x.toFixed(4);
  return `matrix(${v(px[0] - p0[0])} ${v(px[1] - p0[1])} ${v(py[0] - p0[0])} ${v(py[1] - p0[1])} ${p0[0].toFixed(2)} ${p0[1].toFixed(2)})`;
}

const CABLES = [];
function cable(name, points, r = 0.5, { tone = "mid", bend = 3, ends = [true, true], shells = [false, false], rings, maxLength = 16, bias = 0.05, limp = true, touch, bundle } = {}) {
  const route = G.fillet(points, Math.max(bend, r * 2.6), 10, { P, tube: r });
  const total = G.pathLength(route);
  const SHELL = Math.max(1.6, r * 2.4);
  const gaps = [...(shells[0] ? [[0, SHELL]] : []), ...(shells[1] ? [[total - SHELL, total]] : [])];
  R.route(name, route, r, { gaps, limp, touch, bundle, rims: shells.map((on) => (on ? r * 1.9 : r)), piece: "cab" });
  CABLES.push(name);
  tubePieces(route, r, P, { maxLength, spacing: Math.max(0.6, Math.min(1.6, r)), gaps, ...(rings ? { rings } : {}) }).forEach((piece, index) =>
    put(`${name}:${index}`, piece.mid, tubeSvg(piece, { tone }), [A.tube(piece.points, piece.radii)], { bias, route: name, chunk: index, attrs: ` data-route="${name}" data-chunk="${index}"` }),
  );
  [0, 1].forEach((index) => {
    const end = index ? route[route.length - 1] : route[0];
    if (shells[index]) {
      const tangent = G.tangentAlong(route, index ? total : 0);
      const F = G.frameAlong(end, tangent);
      const [s0, s1] = index ? [-SHELL, 0] : [0, SHELL];
      const shape = record(A.disc(F, s0, s1, r * 1.9, { name: `${name}.shell${index}`, owner: name }));
      put(`${name}.shell${index}`, G.add3(end, G.mul3(tangent, (s0 + s1) / 2)), S(G.disc(s0, s1, r * 1.9, F, P, { steps: 20, bevel: 0.2 }), { tone: "mid" }), [shape], { bias: bias + 0.1 });
    } else if (ends[index]) {
      put(`${name}.end${index}`, end, Dots([at(end)], { size: Math.max(0.5, r * KS * 0.9), tone: "hi" }), [A.ball(end, Math.max(0.5, r * KS * 0.9) / KS, { name: `${name}.end${index}`, owner: name })], { bias: bias + 0.15 });
    }
  });
  return route;
}

const back = [];
back.push(`<path class="iso-halo" d="${k.haloOf(PLATE, -PLATE_H - FOOT_H, PLATE_H, P)}"/>`);
const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));
for (const [x, y] of byDepth(k.corners(PLATE, 22))) {
  back.push(S(k.cylinder(x, y, 7, -PLATE_H - FOOT_H, 1.6, P, 28), { tone: "lo" }));
  back.push(S(k.cylinder(x, y, 5.4, -PLATE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" }));
}
back.push(S(k.slabOf(PLATE, -PLATE_H, PLATE_H, P, 8, 1.6), { tone: "mid", inner: Ln(k.sideSeam(PLATE, -4, P, 8), { tone: "faint" }) }));
back.push(Ln(k.planOutline(k.insetPlan(PLATE, 5), 0, P), { tone: "lo" }));
const plateScrews = k.corners(PLATE, 10);
back.push(Ln(plateScrews.map(([x, y]) => k.ring(x, y, 2.2, 0, P, 16)).join(""), { tone: "lo" }));
back.push(Dots(plateScrews.map(([x, y]) => at([x, y, 0])), { size: 0.5 }));
record(A.slab(PLATE, -PLATE_H, PLATE_H, { name: "stand.plate", ground: true }, 8));
const STRIP = { x: PLATE.x + 14, y: PLATE.y + PLATE.d - 13, w: PLATE.w - 28, d: 6, r: 1 };
back.push(S(k.slabOf(STRIP, 0, 0.6, P, 3), { tone: "lo", crease: "none" }));
const ticks = k.topTicks(STRIP.x + 4, STRIP.x + STRIP.w - 4, 4, 5, STRIP.y, 0.6, [1.8, 3.4], P);
back.push(Ln(ticks.minor, { tone: "lo" }), Ln(ticks.major, { tone: "mid" }));
const LABEL = { x: 48, y: PLATE.y + PLATE.d - 34, w: 30, d: 13, r: 1.2 };
back.push(S(k.slabOf(LABEL, 0, 0.8, P, 3), { tone: "lo", crease: "none" }));
back.push(Ln(k.planOutline(k.insetPlan(LABEL, 1.4), 0.8, P, 3), { tone: "faint" }));
back.push(Ln([20, 14, 17].map((w, i) => k.lineOnTop([LABEL.x + 4, LABEL.y + 3.4 + i * 3], [LABEL.x + 4 + w, LABEL.y + 3.4 + i * 3], 0.8, P)).join(""), { tone: "lo", free: true }));
back.push(Dots([at([LABEL.x + LABEL.w - 2.6, LABEL.y + 2.4, 0.8]), at([LABEL.x + LABEL.w - 2.6, LABEL.y + LABEL.d - 2.4, 0.8])], { size: 0.45 }));

const TRAY = { x: -74, y: 188 };
const STACK = 7;
back.push(`<path class="iso-halo" d="${k.haloOf({ x: TRAY.x - 9, y: TRAY.y - 9, w: 18, d: 18, r: 9 }, 0, 2, P)}"/>`);
const COIN_STEP = 0.42;
const stackTop = (n) => 1.6 + n * COIN_STEP;

const BEZEL_T = [110, 306];
const BEZEL_B = [136, 195];
const WALL = [[0, 4], [156, 4], [156, 168], [178, 170], [178, 184], BEZEL_B, BEZEL_T, [126, 314], [126, 358], [0, 358]];
const bd = G.unit3([0, BEZEL_B[0] - BEZEL_T[0], BEZEL_B[1] - BEZEL_T[1]]);
const NB = [0, -bd[2], bd[1]];
const C3 = [0, (BEZEL_T[0] + BEZEL_B[0]) / 2, (BEZEL_T[1] + BEZEL_B[1]) / 2];
const CPD = G.unit3([0, 178 - 136, 184 - 195]);
const CPN = [0, -CPD[2], CPD[1]];
const cpTop = (y) => 195 + ((y - 136) * (184 - 195)) / (178 - 136);

for (const [x, y] of byDepth([[-54, 12], [54, 12], [-54, 144], [54, 144]])) {
  const F = G.frameOf([x, y, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
  const shapes = [record(A.disc(F, 0, 1.2, 3.4, { name: `leveler.pad` })), record(A.disc(F, 1.2, 2.4, 2.4, { name: `leveler.nut` })), record(A.disc(F, 2.4, 4, 1, { name: `leveler.stem` }))];
  put(`leveler`, [x, y, 2], S(G.disc(0, 1.2, 3.4, F, P, { steps: 32 }), { tone: "lo" }) + S(G.disc(1.2, 2.4, 2.4, F, P, { steps: 6 }), { tone: "mid" }) + S(G.disc(2.4, 4, 1, F, P, { steps: 16 }), { tone: "mid" }), shapes);
}

const wallPaths = prismPaths(XF, WALL, XI, XW);
const ringW = signed(WALL) > 0 ? WALL : [...WALL].reverse();
const tFacing = ringW.map(([y0, z0], i) => {
  const [y1, z1] = ringW[(i + 1) % ringW.length];
  return G.dot3(G.unit3([0, z1 - z0, -(y1 - y0)]), V) > 1e-9;
});
const bead = runsOf(tFacing, true).map((run) => path3(run.map((i) => [62, ...ringW[i]]))).join("");
{
  const shape = record(A.prism(XF, WALL, XI, XW, { name: "wall" }));
  put("wall", [XW, 80, 180], S(wallPaths, { tone: "mid", inner: Ln(bead, { tone: "lo" }) }), [shape], { bias: -400 });
}
box("cleat.floor", [56, 8, 8], [60, 148, 12], { tone: "lo" });
box("cleat.back", [56, 4, 12], [60, 8, 350], { tone: "lo" });
box("cleat.top", [56, 8, 350], [60, 122, 354], { tone: "lo" });
box("cleat.front", [56, 148, 12], [60, 152, 164], { tone: "lo" });

board("floor", [[4, 4], [152, 4], [152, 8], [4, 8]], -XI, XI, { tone: "lo" }, { holes: [[24, 6], [130, 6]] });
board("back", [[0, 4], [4, 4], [4, 354], [0, 354]], -XI, XI, {}, { holes: [[2, 40], [2, 180], [2, 320]], bias: -300 });
board("top", [[0, 354], [126, 354], [126, 358], [0, 358]], -XI, XI, {}, {
  holes: [[30, 356], [100, 356]],
  extra: Ln(Array.from({ length: 9 }, (_, i) => k.planOutline({ x: -36 + i * 8, y: 14, w: 3, d: 30, r: 1.5 }, 358, P, 3)).join(""), { tone: "faint" }),
});
board("front", [[152, 4], [156, 4], [156, 168], [152, 168]], -XI, XI, {}, { holes: [[154, 20], [154, 150]] });
box("kick", [-XI, 156, 6], [XI, 156.8, 24], { tone: "lo" }, { r: 0.3, extra: Dots([-54, -18, 18, 54].flatMap((x) => [at([x, 156.8, 9]), at([x, 156.8, 21])]), { size: 0.45, tone: "hi" }) });
board("marquee.shelf", [[98, 318], [126, 318], [126, 322], [98, 322]], -XI, XI, { tone: "lo" });
const SPK = [[110, 306], [126, 314], [124.2, 317.6], [108.2, 309.6]];
board("speaker.board", SPK, -XI, XI);
board("cp.top", [[136, 195], [178, 184], [177.24, 181.1], [135.24, 192.1]], -XI, XI, { tone: "mid" }, { holes: [[157, 188]] });
board("cp.front", [[174, 170], [178, 170], [178, 180.89], [174, 181.94]], -XI, XI);
board("cp.bottom", [[152, 168], [174, 170], [174, 174], [152, 172]], -XI, XI, { tone: "lo" });
{
  const m = planeMatrix([0, 125.02, 338], [1, 0, 0], [0, 0, -1]);
  const sc = 1.7;
  const w = (7 * 8 - 3) * sc;
  const art = `<g class="ac-marquee" transform="${m}"><path class="ac-marquee-art" d="${SCREEN.text("ANATOMY", -w / 2, -6, sc)}"/><rect class="ac-marquee-frame" x="-56" y="-13.6" width="112" height="27.2" rx="2"/></g>`;
  board("marquee.plexi", [[123, 322], [125, 322], [125, 354], [123, 354]], -XI, XI, { tone: "faint" }, { extra: art });
}
const TUBE_F = G.frameOf([-46, 110, 338], [1, 0, 0], [0, 1, 0], [0, 0, 1]);
turned("marquee.tube", TUBE_F, [[0, 3.6], [92, 3.6]], { tone: "hi" }, { inner: Ln(G.arcOf(TUBE_F, 4, 3.6, P) + G.arcOf(TUBE_F, 88, 3.6, P), { tone: "faint" }), extra: "" });
box("marquee.socket", [-51, 106, 333], [-46, 114, 342], { tone: "mid" });
box("marquee.socket", [46, 106, 333], [51, 114, 342], { tone: "mid" });
box("marquee.bracket", [-51, 106, 342], [-49, 114, 354], { tone: "lo" });
box("marquee.bracket", [49, 106, 342], [51, 114, 354], { tone: "lo" });
box("marquee.ballast", [8, 99, 322], [40, 105, 327], { tone: "mid" }, { inner: Ln(k.sideSeam({ x: 8, y: 99, w: 32, d: 6, r: 0.4 }, 324.5, P, 2), { tone: "faint" }), extra: Dots([at([10, 105, 327]), at([38, 105, 327])], { size: 0.45 }) });
turned("marquee.starter", G.frameOf([44, 103, 322], [0, 0, 1], [1, 0, 0], [0, 1, 0]), [[0, 1.8], [5, 1.8], [5.6, 1.3, 1], [6, 0.6, 1]], { tone: "mid" });

const SPK_N = G.unit3([0, -8, 16]);
const SPK_C = [22, 115, 313.0];
const SPK_F = G.frameAlong(SPK_C, SPK_N);
turned("speaker.basket", SPK_F, [[0, 5.6], [0.8, 5.6], [0.8, 4.8], [2.4, 2.8, 1], [2.8, 2.4]], { tone: "mid" });
disc("speaker.magnet", SPK_F, 2.8, 3.8, 2.6, { tone: "lo" });

const MON_A = G.mul3(NB, -1);
const CF = G.add3(C3, G.mul3(NB, -5));
const MON = G.frameOf(CF, MON_A, [1, 0, 0], bd);
const BEZEL_OPEN = { u: 29.5, w: 37.5, r: 4 };
const monPoint = (u, w, s) => G.add3(G.add3(G.add3(CF, G.mul3([1, 0, 0], u)), G.mul3(bd, w)), G.mul3(MON_A, s));
const funnel = (s) => 3 + 31 * Math.pow(Math.max(0, (64 - s) / 55), 1.4);
const funnelSlope = (s) => (-31 * 1.4 * Math.pow(Math.max(1e-6, (64 - s) / 55), 0.4)) / 55;
prism("crt.face", MON, rect(-34, 34, -42, 42).map((p) => p), 0, 3, { tone: "hi" });
const BAND = [[-35, -43], [35, -43], [35, 43], [-35, 43]];
prism("crt.band", MON, BAND, 3, 9, { tone: "mid" }, { inner: "" });
for (const [su, sv] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
  const ear = [[su * 35, sv * 37], [su * 41, sv * 37], [su * 41, sv * 43], [su * 35, sv * 43]];
  prism("crt.ear", MON, ear, 5, 6, { tone: "mid" }, { extra: Dots([at(monPoint(su * 38.6, sv * 40, 5))], { size: 0.5, tone: "hi" }) });
}
const FUNNEL = G.bandsOf([[9, funnel(9)], [62, funnel(62)]], [48], MON, P, { radius: funnel, slope: funnelSlope, rows: 6 });
const funnelFront = FUNNEL.bands[0];
{
  const shape = record(A.solid(MON, funnelFront.profile, { name: "crt.funnel", radius: funnel }));
  const sides = FUNNEL.sidesOf(9, 48, { ends: false });
  put("crt.funnel", G.add3(CF, G.mul3(MON_A, 26)), S(funnelFront.paths, { tone: "hi" }) + Ln(sides.d, { tone: "hi" }), [shape]);
}
turned("crt.yoke", MON, [[48, 15.5], [52, 15.5], [60, 11, 1], [64, 8]], { tone: "mid" }, { inner: Ln(G.arcOf(MON, 52, 15.5, P), { tone: "faint" }) });
for (const [s0, s1] of [[64, 69], [70.4, 72.4], [73.8, 88.4]]) disc("crt.neck", MON, s0, s1, 3, { tone: "hi" });
disc("crt.ring", MON, 69, 70.4, 4.2, { tone: "mid" });
disc("crt.ring", MON, 72.4, 73.8, 4.2, { tone: "mid" });
prism("crt.neckboard", MON, rect(-7, 7, -7, 7), 90, 91, { tone: "mid" }, { extra: "" });
disc("crt.socket", MON, 88.4, 90, 4.4, { tone: "lo" });
const ANODE_ANGLE = -2.35;
const anodeAt = G.pointOf(MON, 30, funnel(30), ANODE_ANGLE);
const anodeN = G.unit3(G.sub3(G.radialOf(MON, ANODE_ANGLE), G.mul3(MON.a, funnelSlope(30))));
const ANODE_F = G.frameAlong(G.add3(anodeAt, G.mul3(anodeN, -0.4)), anodeN);
turned("crt.anode", ANODE_F, [[0, 3.6], [0.8, 3.4, 1], [1.8, 2.4, 1], [2.4, 1.2, 1], [2.6, 0.6]], { tone: "lo" });

for (const su of [-1, 1]) prism("mon.angle", MON, [[su * 41, -46], [su * 44, -46], [su * 44, 46], [su * 41, 46]].map(([u, w]) => [u, w]), 4, 8, { tone: "mid" });
for (const su of [-1, 1]) prism("mon.rail", MON, [[su * 36, 46], [su * 44, 46], [su * 44, 48], [su * 36, 48]], 4, 66, { tone: "mid" }, { extra: Dots([at(monPoint(su * 40, 46, 20)), at(monPoint(su * 40, 46, 50))], { size: 0.45 }) });
prism("mon.crossbar", MON, [[-36, 46], [36, 46], [36, 48], [-36, 48]], 62, 66, { tone: "mid" });
prism("mon.shelf", MON, [[-XI, 48], [XI, 48], [XI, 52], [-XI, 52]], -2, 70, { tone: "lo" });
prism("mon.cleat", MON, [[56, 52], [XI, 52], [XI, 56], [56, 56]], 2, 66, { tone: "lo" });
board("mon.post", [[130, 174], [134, 174], [134, 194.6], [130, 195.6]], -XI, XI, { tone: "lo" });

const CH = G.frameOf(monPoint(-44, 0, 0), [-1, 0, 0], MON_A, bd);
const chPoint = (s, w, out = 0) => G.add3(G.add3(G.add3(monPoint(-44, 0, 0), G.mul3(MON_A, s)), G.mul3(bd, w)), [-out, 0, 0]);
prism("chassis.board", CH, [[22, 14], [64, 14], [64, 46], [22, 46]], 0, 1, { tone: "lo" });
prism("chassis.flyback", CH, [[46, 18], [56, 18], [56, 29], [46, 29]], 1, 7, { tone: "mid" }, { inner: "" });
disc("chassis.flyback.coil", G.frameAlong(chPoint(51, 23.5, 7), [-1, 0, 0]), 0, 2.6, 4, { tone: "hi" });
for (const [s, w] of [[48, 31], [53, 31]]) disc("chassis.pot", G.frameAlong(chPoint(s, w, 1), [-1, 0, 0]), 0, 1.6, 1.1, { tone: "mid" }, { extra: Ln(k.pathOf([at(chPoint(s - 0.8, w, 2.7)), at(chPoint(s + 0.8, w, 2.7))]), { tone: "hi" }) });
prism("chassis.heatsink", CH, [[26, 17], [38, 17], [38, 27], [26, 27]], 1, 6, { tone: "mid" }, {
  extra: Ln([28, 30, 32, 34, 36].map((s) => k.pathOf([at(chPoint(s, 17, 6)), at(chPoint(s, 27, 6))])).join(""), { tone: "lo" }),
});
for (const [s, w, r, h] of [[30, 34, 1.8, 5], [35, 38, 1.4, 4], [47, 40, 1.6, 4.4], [42, 42, 1.2, 3.4]]) disc("chassis.cap", G.frameAlong(chPoint(s, w, 1), [-1, 0, 0]), 0, h, r, { tone: "hi" }, { inner: "" });
prism("chassis.plug.video", CH, [[56, 36], [62, 36], [62, 42], [56, 42]], 1, 3, { tone: "mid" });
prism("chassis.plug.neck", CH, [[57, 18], [62, 18], [62, 23], [57, 23]], 1, 3, { tone: "mid" });

const JOY = { x: -49, y: 160 };
const J_O = [JOY.x, JOY.y, cpTop(JOY.y)];
const CPF = (o) => G.frameOf(o, CPN, [1, 0, 0], CPD);
const cpPoint = (x, y, s = 0) => G.add3([x, y, cpTop(y)], G.mul3(CPN, s));
const overlay = [[-56, 139], [56, 139], [56, 175], [-56, 175]].map(([x, y]) => cpPoint(x, y, 0.05));
const STICK_DATA = {
  o: J_O,
  nc: CPN,
  dc: CPD,
  pivot: -4,
  switches: [
    { id: "left", c: G.add3(cpPoint(JOY.x - 4.6, JOY.y), G.mul3(CPN, -10.4)), out: [-1, 0, 0] },
    { id: "right", c: G.add3(cpPoint(JOY.x + 4.6, JOY.y), G.mul3(CPN, -10.4)), out: [1, 0, 0] },
    { id: "up", c: G.add3(G.add3(cpPoint(JOY.x, JOY.y), G.mul3(CPD, -4.6)), G.mul3(CPN, -10.4)), out: G.mul3(CPD, -1) },
    { id: "down", c: G.add3(G.add3(cpPoint(JOY.x, JOY.y), G.mul3(CPD, 4.6)), G.mul3(CPN, -10.4)), out: CPD },
  ],
};
const PARTS = makeParts(k, G, P, { stick: STICK_DATA });
const JF = CPF(J_O);
const plateShape = record(A.prism(JF, rect(-9, 9, -9, 9), -3.6, -3, { name: "stick.plate" }));
put("stick.plate", G.add3(J_O, G.mul3(CPN, -3.3)), S(prismPaths(JF, rect(-9, 9, -9, 9), -3.6, -3), { tone: "mid" }), [plateShape]);
const boltHeads = [[-7, -7], [7, -7], [-7, 7], [7, 7]].map(([u, v]) => at(G.add3(G.add3(J_O, G.mul3([1, 0, 0], u)), G.mul3(CPD, v))));
for (const sw of STICK_DATA.switches) {
  const side = G.cross3(CPN, sw.out);
  const F = G.frameOf(G.add3(sw.c, G.mul3(sw.out, 0.65)), CPN, sw.out, side);
  prism("stick.bracket", F, [[0, -2.2], [0.5, -2.2], [0.5, 2.2], [0, 2.2]], -1, 6.8, { tone: "lo" });
}
disc("stick.bushing", JF, -5.2, -3.6, 2.2, { tone: "lo" }, { owner: "stick" });
const PIV = G.frameAlong(G.add3(J_O, G.mul3(CPN, -4)), CPN);
const stickLowerShapes = [
  record(A.disc(PIV, -8.6, -4.6, 2.4, { name: "stick.actuator", owner: "stick" })),
  record(A.disc(PIV, -9.4, -0.6, 0.85, { name: "stick.shaft.lower", owner: "stick" })),
  record(A.disc(PIV, -10, -9.4, 1.5, { name: "stick.clip", owner: "stick" })),
  ...STICK_DATA.switches.map((sw) => record(A.prism(G.frameOf(sw.c, CPN, sw.out, G.cross3(CPN, sw.out)), rect(-0.65, 0.65, -2, 2), -1, 1, { name: `stick.switch.${sw.id}` }))),
  ...STICK_DATA.switches.flatMap((sw) => [-1.1, 0, 1.1].map((o) => record(A.prism(G.frameOf(G.add3(G.add3(sw.c, G.mul3(CPN, -1)), G.mul3(G.cross3(CPN, sw.out), o)), CPN, sw.out, G.cross3(CPN, sw.out)), rect(-0.3, 0.3, -0.35, 0.35), -1, 0, { name: `stick.tab.${sw.id}`, owner: `stick.switch.${sw.id}` })))),
];
put("stick.lower", G.add3(J_O, G.mul3(CPN, -9)), `<g data-part="stick-lower">${PARTS.stickLower(0, 0)}</g>`, stickLowerShapes);
put("stick.bolts", J_O, Dots(boltHeads, { size: 0.55, tone: "mid" }), [[-7, -7], [7, -7], [-7, 7], [7, 7]].map(([u, v]) => record(A.ball(G.add3(G.add3(J_O, G.mul3([1, 0, 0], u)), G.mul3(CPD, v)), 0.4, { name: "stick.bolt" }))), { bias: 0.1 });
put("stick.upper", G.add3(J_O, G.mul3(CPN, 8)), `<g data-part="stick-upper">${PARTS.stickUpper(0, 0)}</g>`, [record(A.ball(G.add3(J_O, G.mul3(CPN, 12.4)), 3.5, { name: "stick.ball", owner: "stick" })), record(A.disc(G.frameAlong(J_O, CPN), 0.4, 8.95, 0.85, { name: "stick.shaft", owner: "stick" })), record(A.disc(G.frameAlong(J_O, CPN), 0, 0.4, 4.4, { name: "stick.washer", owner: "stick" }))], { bias: 2 });

const BUTTONS = [
  { id: "fire", x: -18, y: 158, lamp: false },
  { id: "jump", x: -6, y: 162, lamp: false },
  { id: "p1", x: 18, y: 146, lamp: true },
  { id: "p2", x: 32, y: 146, lamp: true },
];
for (const b of BUTTONS) {
  const o = cpPoint(b.x, b.y);
  const F = CPF(o);
  const r = b.lamp ? 2.2 : 2.6;
  const bez = record(A.disc(F, 0, 0.7, r + 1, { name: `button.${b.id}.bezel` }));
  put(`button.${b.id}.bezel`, o, S(G.disc(0, 0.7, r + 1, F, P, { steps: 36 }), { tone: "mid" }), [bez], { bias: 0.5 });
  const plunger = record(A.disc(F, 0.7, 2.2, r, { name: `button.${b.id}` }));
  const cap = S(G.disc(0.7, 2.2, r, F, P, { steps: 32 }), { tone: "hi" }) + Ln(G.circleOf(F, 2.2, r * 0.68, P, 24), { tone: "lo" });
  put(`button.${b.id}`, G.add3(o, G.mul3(CPN, 1.5)), `<g data-button="${b.id}"${b.lamp ? ' class="ac-lamp"' : ""}>${cap}</g>`, [plunger], { bias: 0.8 });
  disc(`button.${b.id}.housing`, F, -8, -4.6, 2.3, { tone: "mid" });
  disc(`button.${b.id}.nut`, F, -4.6, -3, 3.6, { tone: "lo" }, { steps: 6 });
  const SW = G.frameOf(G.add3(o, G.mul3(CPN, -8)), CPN, [1, 0, 0], CPD);
  prism(`button.${b.id}.switch`, SW, rect(-2, 2, -1.3, 1.3), -4.2, 0, { tone: "mid" });
}

const DOOR = { x0: -57, x1: -3, z0: 38, z1: 120 };
const DOOR_CX = (DOOR.x0 + DOOR.x1) / 2;
const FY = 156;
box("door.frame", [DOOR.x0, FY, DOOR.z0], [DOOR.x1, FY + 1.4, DOOR.z1], { tone: "lo" }, { r: 1.2, steps: 3 });
box("door.plate", [DOOR.x0 + 2, FY + 1.4, DOOR.z0 + 2], [DOOR.x1 - 2, FY + 2.4, DOOR.z1 - 2], { tone: "mid" }, { r: 1, steps: 3, bevel: 0.4 });
const ENTRIES = [-42, -18];
const SLOT_Z = [100.5, 107.5];
for (const cx of ENTRIES) {
  const front = FY + 3.8;
  box("door.entry", [cx - 8, FY + 2.4, 88], [cx + 8, front, 114], { tone: "mid" }, { r: 1.2, steps: 3, bevel: 0.3 });
  const slot = [[cx - 0.5, SLOT_Z[0]], [cx + 0.5, SLOT_Z[0]], [cx + 0.5, SLOT_Z[1]], [cx - 0.5, SLOT_Z[1]]];
  const lamp = `<g class="ac-lamp" data-lamp="reject">${S(k.slabOf({ x: cx - 5, y: front, w: 10, d: 1.2, r: 0.6 }, 90, 8, P, 2, 0.3), { tone: "hi" })}</g>`;
  put("door.slot", [cx, front + 0.1, 104], `<path class="ac-slot" d="${path3(slot.map(([x, z]) => [x, front + 0.02, z]), true)}"/>` + lamp, [record(A.box([cx - 5, front, 90], [cx + 5, front + 1.2, 98], { name: "door.reject" }))], { bias: 1 });
}
disc("door.lock", G.frameAlong([DOOR_CX, FY + 2.4, 72], [0, 1, 0]), 0, 1.6, 2.4, { tone: "hi" }, { extra: Ln(path3([[DOOR_CX, FY + 4.02, 70.8], [DOOR_CX, FY + 4.02, 73.2]]), { tone: "lo", free: true }) });
box("door.return", [DOOR_CX - 9, FY + 2.4, 46], [DOOR_CX + 9, FY + 4.4, 60], { tone: "mid" }, { r: 1.4, steps: 3 });
put("door.return.mouth", [DOOR_CX, FY + 4.5, 52], `<path class="ac-slot" d="${path3([[DOOR_CX - 6, FY + 4.42, 49], [DOOR_CX + 6, FY + 4.42, 49], [DOOR_CX + 6, FY + 4.42, 55], [DOOR_CX - 6, FY + 4.42, 55]], true)}"/>`, [record(A.box([DOOR_CX - 6, FY + 4.4, 49], [DOOR_CX + 6, FY + 4.5, 55], { name: "door.return.mouth" }))], { bias: 1 });
for (const z of [48, 110]) disc("door.hinge", G.frameAlong([DOOR.x1 + 0.9, FY + 0.9, z], [0, 0, 1]), -3, 3, 0.9, { tone: "mid" });
box("door.price", [DOOR_CX - 6, FY + 2.4, 80], [DOOR_CX + 6, FY + 2.9, 85], { tone: "lo" }, { extra: Ln(path3([[DOOR_CX - 4, FY + 2.92, 82.5], [DOOR_CX + 2, FY + 2.92, 82.5]]), { tone: "lo", free: true }) });

box("door.back", [DOOR.x0, 150, DOOR.z0], [DOOR.x1, 152, DOOR.z1], { tone: "lo" });
const MECH = (cx) => ({ x0: cx - 6.5, x1: cx + 6.5, y0: 141, y1: 150, z0: 84, z1: 106 });
for (const cx of ENTRIES) {
  const m = MECH(cx);
  const side = m.x0;
  const extra = [
    Ln(path3([[side, m.y0 + 2, m.z0 + 4], [side, m.y1 - 2, m.z0 + 4], [side, m.y1 - 2, m.z1 - 4], [side, m.y0 + 2, m.z1 - 4]], true), { tone: "faint" }),
    Dots([at([side, m.y0 + 2.4, m.z1 - 2.4]), at([side, m.y1 - 2.4, m.z1 - 2.4]), at([side, m.y0 + 2.4, m.z0 + 2.4]), at([side, m.y1 - 2.4, m.z0 + 2.4])], { size: 0.45 }),
  ].join("");
  box(`mech`, [m.x0, m.y0, m.z0], [m.x1, m.y1, m.z1], { tone: "mid" }, { r: 0.6, steps: 2, bevel: 0.4, extra });
  disc("mech.magnet", G.frameAlong([side, 145.5, 96], [-1, 0, 0]), 0, 1.4, 2.4, { tone: "hi" });
  disc("mech.pivot", G.frameAlong([side, 146, 89], [-1, 0, 0]), 0, 0.8, 0.9, { tone: "lo" });
  box("mech.lever", [cx - 1, 141.5, 106], [cx + 1, 150, 107.6], { tone: "lo" });
  disc("door.socket", G.frameAlong([cx, 150, 112], [0, -1, 0]), 0, 3.4, 1.6, { tone: "mid" });
}

const CHUTE = { x0: -50, x1: -10, y0: 148, y1: 104, z0: 82, slope: Math.tan((30 * Math.PI) / 180) };
const chuteZ = (y) => CHUTE.z0 - CHUTE.slope * (CHUTE.y0 - y);
const CH_T = 0.8;
const cs = Math.cos((30 * Math.PI) / 180);
const sn = Math.sin((30 * Math.PI) / 180);
const chuteFloor = [[CHUTE.y0, chuteZ(CHUTE.y0)], [CHUTE.y1, chuteZ(CHUTE.y1)], [CHUTE.y1 + CH_T * sn, chuteZ(CHUTE.y1) - CH_T * cs], [CHUTE.y0 + CH_T * sn, chuteZ(CHUTE.y0) - CH_T * cs]];
board("chute.floor", chuteFloor, CHUTE.x0, CHUTE.x1, { tone: "mid" });
const wallOf = (h) => [[CHUTE.y0, chuteZ(CHUTE.y0)], [CHUTE.y0, chuteZ(CHUTE.y0) + h], [CHUTE.y1, chuteZ(CHUTE.y1) + h], [CHUTE.y1, chuteZ(CHUTE.y1)]];
board("chute.far", wallOf(6), CHUTE.x1, CHUTE.x1 + 0.7, { tone: "mid" });
board("chute.lip", wallOf(1.2), CHUTE.x0 - 0.7, CHUTE.x0, { tone: "hi" });
box("chute.tab", [CHUTE.x0 + 2, CHUTE.y0, chuteZ(CHUTE.y0) - 2.6], [CHUTE.x1 - 2, 150, chuteZ(CHUTE.y0) - CH_T * cs], { tone: "lo" });
for (const x of [CHUTE.x0 + 1, CHUTE.x1 - 3]) box("chute.leg", [x, 109, 8], [x + 1.4, 111, chuteZ(109 - CH_T * sn) - CH_T * cs], { tone: "lo" });

const SW_Y = 124;
const SWITCH = { x0: -54.4, x1: -50.7, y0: SW_Y - 2.6, y1: SW_Y + 2.6, z0: chuteZ(SW_Y) - 2.4, z1: chuteZ(SW_Y) + 3 };
box("coin.switch", [SWITCH.x0, SWITCH.y0, SWITCH.z0], [SWITCH.x1, SWITCH.y1, SWITCH.z1], { tone: "mid" }, { extra: Dots([at([SWITCH.x0, SW_Y - 1.4, SWITCH.z0 + 1]), at([SWITCH.x0, SW_Y + 1.4, SWITCH.z1 - 1])], { size: 0.4 }) });
box("coin.switch.bracket", [SWITCH.x1, SW_Y - 2.2, SWITCH.z0], [CHUTE.x0 - 0.7, SW_Y + 2.2, SWITCH.z0 + 1], { tone: "lo" });
const WIRE = { from: [SWITCH.x1, SW_Y, chuteZ(SW_Y) + 1.4], length: 13 };

const BOX = { x0: -58, x1: -16, y0: 62, y1: 104, z0: 8, z1: 30 };
const T = 0.7;
box("cash.floor", [BOX.x0, BOX.y0, BOX.z0], [BOX.x1, BOX.y1, BOX.z0 + T], { tone: "lo" });
box("cash.back", [BOX.x0, BOX.y0, BOX.z0 + T], [BOX.x1, BOX.y0 + T, BOX.z1], { tone: "mid" });
box("cash.far", [BOX.x1 - T, BOX.y0 + T, BOX.z0 + T], [BOX.x1, BOX.y1, BOX.z1], { tone: "mid" });
box("cash.near", [BOX.x0, BOX.y0 + T, BOX.z0 + T], [BOX.x0 + T, BOX.y1, BOX.z1], { tone: "mid" });
box("cash.front", [BOX.x0 + T, BOX.y1 - T, BOX.z0 + T], [BOX.x1 - T, BOX.y1, BOX.z1], { tone: "mid" });
box("cash.handle", [BOX.x0 - 1.6, 78, 22], [BOX.x0, 88, 24], { tone: "hi" });

const METER = { x0: -58, x1: -52, y0: 108, y1: 118, z0: 12, z1: 20 };
box("meter.foot", [-58, 106, 8], [-50, 120, 12], { tone: "lo" });
box("meter", [METER.x0, METER.y0, METER.z0], [METER.x1, METER.y1, METER.z1], { tone: "mid" }, { r: 0.6 });
const METER_FACE = { o: [METER.x0 - 0.02, 109.6, 18.4], ex: [0, 1, 0], ey: [0, 0, -1] };
{
  const m = planeMatrix(METER_FACE.o, METER_FACE.ex, METER_FACE.ey);
  put("meter.face", [METER.x0, 113, 16], `<g transform="${m}"><rect class="ac-window" x="0" y="0" width="6.8" height="4.8" rx="0.4"/><text class="ac-digits" data-part="meter" x="0.35" y="3.55" font-size="3" textLength="6.1" lengthAdjust="spacingAndGlyphs">004127</text></g>`, [record(A.box([METER.x0 - 0.05, 109.6, 13.6], [METER.x0, 116.4, 18.4], { name: "meter.face" }))], { bias: 0.3 });
}

const PCB = { y: 8, x0: -50, x1: 10, z0: 116, z1: 170 };
const px = (v) => PCB.x0 + v;
const pz = (v) => PCB.z0 + v;
const FACE_Y = PCB.y;
const BF = G.frameOf([0, FACE_Y, 0], [0, 1, 0], [1, 0, 0], [0, 0, 1]);
for (const [x, z] of [[3, 3], [57, 3], [3, 51], [57, 51]]) disc("pcb.standoff", G.frameAlong([px(x), 4, pz(z)], [0, 1, 0]), 0, 3.2, 1, { tone: "lo" });
box("pcb.board", [PCB.x0, FACE_Y - 0.8, PCB.z0], [PCB.x1, FACE_Y, PCB.z1], { tone: "lo" }, { r: 0.6 });
const chip = (name, x, z, len, wid, tall = 1.2) => {
  const y1 = FACE_Y + tall;
  const pins = [];
  for (let i = 0.6; i < len - 0.3; i += 1.27) pins.push(at([px(x + i), FACE_Y, pz(z) - 0.25]), at([px(x + i), FACE_Y, pz(z + wid) + 0.25]));
  box(name, [px(x), FACE_Y, pz(z)], [px(x + len), y1, pz(z + wid)], { tone: "hi" }, { r: 0.15, steps: 1, extra: Dots(pins, { size: 0.22, tone: "lo" }) + Dots([at([px(x + 0.8), y1, pz(z + wid / 2)])], { size: 0.35, tone: "lo" }) });
};
chip("pcb.cpu", 4, 40, 10.4, 3);
for (let i = 0; i < 4; i++) chip("pcb.rom", 18 + i * 8.6, 45, 7.2, 3);
for (let i = 0; i < 3; i++) chip("pcb.ram", 4 + i * 6, 33, 4.6, 1.6);
for (let r = 0; r < 3; r++) for (let c = 0; c < 6; c++) chip("pcb.logic", 24 + c * 5.6, 12 + r * 7.4, 4, 1.6);
chip("pcb.sound", 4, 22, 5.4, 3);
chip("pcb.video", 4, 12, 7.2, 3, 1.6);
box("pcb.crystal", [px(17), FACE_Y, pz(41)], [px(20), FACE_Y + 1.4, pz(42.6)], { tone: "hi" }, { r: 0.6, steps: 3 });
box("pcb.dips", [px(16), FACE_Y, pz(33)], [px(24), FACE_Y + 0.8, pz(35)], { tone: "mid" }, { extra: Dots(Array.from({ length: 8 }, (_, i) => at([px(16.5 + i), FACE_Y + 0.8, pz(34)])), { size: 0.25, tone: "hi" }) });
prism("pcb.heatsink", BF, rect(px(4), px(12), pz(3), pz(8)), 0, 4.2, { tone: "mid" }, { extra: Ln([5.6, 7.2, 8.8, 10.4].map((x) => path3([[px(x), FACE_Y + 4.2, pz(3)], [px(x), FACE_Y + 4.2, pz(8)]])).join(""), { tone: "lo" }) });
for (const [x, z, r, h] of [[50, 37.4, 1.6, 3.6], [56, 37.4, 1.2, 2.8], [16, 26, 1, 2.4], [18, 16, 1, 2.4]]) disc("pcb.cap", G.frameAlong([px(x), FACE_Y, pz(z)], [0, 1, 0]), 0, h, r, { tone: "hi" });
put("pcb.led", [px(54), FACE_Y + 0.4, pz(48)], `<g class="ac-led" data-led="pcb">${Dots([at([px(54), FACE_Y + 0.4, pz(48)])], { size: 0.9, tone: "hi" })}</g>`, [record(A.ball([px(54), FACE_Y + 0.4, pz(48)], 0.6, { name: "pcb.led" }))], { bias: 0.4 });
{
  const traces = [];
  const pads = [];
  const lane = (x0, z0, x1, z1, via) => {
    const pts = via ? [[x0, z0], [via, z0], [via, z1], [x1, z1]] : [[x0, z0], [x1, z0], [x1, z1]];
    traces.push(path3(pts.map(([x, z]) => [px(x), FACE_Y + 0.02, pz(z)])));
    pads.push(at([px(x0), FACE_Y + 0.02, pz(z0)]), at([px(x1), FACE_Y + 0.02, pz(z1)]));
  };
  lane(15, 41.4, 18, 39, 16.6);
  lane(26.6, 45, 29, 37);
  lane(35.2, 45, 37.6, 37);
  lane(9, 39, 9, 35);
  lane(15, 34.6, 24, 27.6, 20);
  lane(9.6, 25, 24, 23.2, 16);
  lane(11.4, 15, 24, 11.4, 14);
  lane(34, 9.4, 44, 4, 39);
  lane(50, 18, 54, 26);
  traces.push(path3([[px(58), FACE_Y + 0.02, pz(2)], [px(58), FACE_Y + 0.02, pz(52)]]));
  pads.push(at([px(58), FACE_Y + 0.02, pz(2)]), at([px(58), FACE_Y + 0.02, pz(52)]));
  put("pcb.traces", [px(30), FACE_Y + 0.05, pz(27)], Ln(traces.join(""), { tone: "faint" }) + Dots(pads, { size: 0.3, tone: "lo" }), [record(A.box([PCB.x0, FACE_Y, PCB.z0], [PCB.x1, FACE_Y + 0.05, PCB.z1], { name: "pcb.traces" }))], { bias: 0.05 });
}
const EDGE = { x0: PCB.x1, x1: PCB.x1 + 4.6, y0: 4.6, y1: 11, z0: 124, z1: 162 };
box("pcb.edge", [EDGE.x0, EDGE.y0, EDGE.z0], [EDGE.x1, EDGE.y1, EDGE.z1], { tone: "mid" }, { r: 0.5, extra: Ln(path3([[EDGE.x0 + 1, EDGE.y1, EDGE.z0], [EDGE.x0 + 1, EDGE.y1, EDGE.z1]]), { tone: "lo" }) });
const plugAt = (z) => [EDGE.x1, 7.8, z];
box("pcb.vplug", [px(6), 5.4, PCB.z1], [px(12), 10.4, PCB.z1 + 2.4], { tone: "mid" });

const FAN = { x: -18, z: 262, r: 12 };
const FANF = G.frameOf([FAN.x, 4, FAN.z], [0, 1, 0], [1, 0, 0], [0, 0, 1]);
prism("fan.frame", FANF, rect(-13, 13, -13, 13), 0, 3.4, { tone: "mid" }, {
  extra: Dots([[-11, -11], [11, -11], [-11, 11], [11, 11]].map(([u, v]) => at([FAN.x + u, 7.4, FAN.z + v])), { size: 0.5, tone: "hi" }),
});
{
  const m = planeMatrix([FAN.x, 7.42, FAN.z], [1, 0, 0], [0, 0, -1]);
  const blades = Array.from({ length: 7 }, (_, i) => {
    const a = (i / 7) * Math.PI * 2;
    const c = Math.cos(a);
    const s = Math.sin(a);
    const pt = (r, da) => [r * Math.cos(a + da), r * Math.sin(a + da)].map((v) => f2(v)).join(" ");
    return `M${pt(3.6, 0)}Q${pt(8, 0.25)} ${pt(11, 0.12)}L${pt(11, 0.62)}Q${pt(7.6, 0.72)} ${pt(3.6, 0.7)}Z`;
  }).join("");
  const guard = [11.6, 8, 4.6].map((r) => `M${r} 0A${r} ${r} 0 1 1 ${-r} 0A${r} ${r} 0 1 1 ${r} 0Z`).join("");
  const spokes = [0, 1, 2, 3].map((i) => `M${f2(3 * Math.cos(i * Math.PI / 2 + 0.4))} ${f2(3 * Math.sin(i * Math.PI / 2 + 0.4))}L${f2(11.6 * Math.cos(i * Math.PI / 2 + 0.4))} ${f2(11.6 * Math.sin(i * Math.PI / 2 + 0.4))}`).join("");
  put("fan.rotor", [FAN.x, 7.5, FAN.z], `<g transform="${m}"><circle class="ac-fan-hole" r="12"/><g data-part="fan"><path class="ac-blade" d="${blades}"/></g><circle class="ac-fan-hub" r="3.4"/><path class="ac-guard" d="${guard}${spokes}"/></g>`, [record(A.disc(FANF, 3.4, 3.5, 12, { name: "fan.rotor" }))], { bias: 0.2 });
}

box("psu.base", [-48, 10, 8], [-20, 40, 9.4], { tone: "lo" }, { extra: Dots([at([-46, 12, 9.4]), at([-22, 12, 9.4]), at([-46, 38, 9.4]), at([-22, 38, 9.4])], { size: 0.45 }) });
box("xfmr.core", [-46, 16, 9.4], [-22, 34, 30], { tone: "mid" }, {
  inner: Ln([12, 15, 18, 21, 24, 27].map((z) => k.sideSeam({ x: -46, y: 16, w: 24, d: 18, r: 0.4 }, z, P, 2)).join(""), { tone: "faint" }),
});
box("xfmr.coil", [-42, 34, 13], [-26, 37, 27], { tone: "hi" }, { r: 1.4, steps: 3 });
box("xfmr.coil.back", [-42, 13, 13], [-26, 16, 27], { tone: "hi" }, { r: 1.4, steps: 3 });
box("xfmr.terminals", [-40, 22, 30], [-28, 28, 32], { tone: "lo" }, { extra: Dots([-38, -35, -32, -29].map((x) => at([x, 25, 32])), { size: 0.45, tone: "hi" }) });
box("psu.case", [-12, 10, 8], [30, 40, 22], { tone: "mid" }, { bevel: 0.4, extra: Dots(k.dotGrid(-8, 13, 14, 8, 2.6, 22, P), { size: 0.35, tone: "lo" }) });
box("psu.strip", [-8, 40, 9], [26, 43, 14], { tone: "lo" }, { extra: Dots([-5, -1, 3, 7, 11, 15, 19, 23].map((x) => at([x, 43, 12.4])), { size: 0.45, tone: "hi" }) });
const PW = { x: 58.6, y0: 14, y1: 58, z0: 28, z1: 70 };
box("pw.plate", [PW.x, PW.y0, PW.z0], [XI, PW.y1, PW.z1], { tone: "lo" }, { extra: Dots([[PW.y0 + 2, PW.z0 + 2], [PW.y1 - 2, PW.z0 + 2], [PW.y0 + 2, PW.z1 - 2], [PW.y1 - 2, PW.z1 - 2]].map(([y, z]) => at([PW.x, y, z])), { size: 0.45, tone: "hi" }) });
box("relay", [PW.x - 7, 18, 52], [PW.x, 30, 64], { tone: "mid" }, { r: 0.6, steps: 2, extra: `<g class="ac-led" data-led="relay">${Dots([at([PW.x - 7, 27.4, 61])], { size: 0.7, tone: "hi" })}</g>` });
for (const z of [56, 62]) {
  box("fuse.holder", [PW.x - 2.6, 36, z - 1.6], [PW.x, 50, z + 1.6], { tone: "mid" }, { r: 1, steps: 3 });
  disc("fuse.cap", G.frameAlong([PW.x - 1.3, 50, z], [0, 1, 0]), 0, 2, 1.3, { tone: "hi" });
}
box("pw.strip", [PW.x - 3, 18, 34], [PW.x, 54, 39], { tone: "mid" }, { extra: Dots([21, 25, 29, 33, 37, 41, 45, 49].map((y) => at([PW.x - 3, y, 36.5])), { size: 0.45, tone: "hi" }) });
box("pw.filter", [PW.x - 6, 18, 42], [PW.x, 34, 49], { tone: "hi" }, { r: 0.8, steps: 2 });
const SV = { x: 58.6, y0: 16, y1: 52, z0: 98, z1: 120 };
box("service.plate", [SV.x, SV.y0, SV.z0], [XI, SV.y1, SV.z1], { tone: "lo" }, { extra: Dots([[SV.y0 + 2, SV.z0 + 2], [SV.y1 - 2, SV.z0 + 2], [SV.y0 + 2, SV.z1 - 2], [SV.y1 - 2, SV.z1 - 2]].map(([y, z]) => at([SV.x, y, z])), { size: 0.45, tone: "hi" }) });
box("service.toggle", [SV.x - 3, 20, 108], [SV.x, 26, 114], { tone: "mid" }, { r: 0.5, extra: Ln(path3([[SV.x - 3, 23, 111], [SV.x - 6, 23, 113.4]]), { tone: "hi", free: "end" }) });
disc("service.button.ring", G.frameAlong([SV.x, 33, 111], [-1, 0, 0]), 0, 1, 3, { tone: "mid" });
disc("service.button", G.frameAlong([SV.x - 1, 33, 111], [-1, 0, 0]), 0, 1.6, 2.1, { tone: "hi" });
disc("service.pot", G.frameAlong([SV.x, 44, 111], [-1, 0, 0]), 0, 1.6, 2.6, { tone: "mid" });
{
  const KF = G.frameAlong([SV.x - 1.6, 44, 111], [-1, 0, 0]);
  disc("service.knob", KF, 0, 2.6, 2.2, { tone: "hi" }, { extra: Faded(G.ribsOf(KF, 0, 2.6, 2.2, 24, P, { fade: [0.1, 0.55], seams: true }), { tone: "lo" }) + Ln(path3([[SV.x - 4.25, 44, 111], [SV.x - 4.25, 45.6, 112.2]]), { tone: "hi", free: "end" }) });
}
disc("cord.grip", G.frameAlong([-30, 4, 22], [0, 1, 0]), 0, 2, 2.6, { tone: "mid" });
box("outlet", [-40, 4, 330], [-10, 8, 338], { tone: "mid" }, { extra: Dots([-34, -25, -16].map((x) => at([x, 8, 334])), { size: 0.5, tone: "lo" }) });
put("back.vents", [20, 4.1, 300], Ln(Array.from({ length: 5 }, (_, i) => path3([[8 + i * 9, 4.05, 290], [13 + i * 9, 4.05, 290], [13 + i * 9, 4.05, 314], [8 + i * 9, 4.05, 314]], true)).join(""), { tone: "faint" }), [record(A.box([8, 4, 290], [50, 4.1, 314], { name: "back.vents" }))], { bias: 0.1 });

const PLUG = { x0: -55, x1: -43, y0: 147.2, y1: 150.4, z0: 173.2, z1: 176.8 };
box("loom.plug", [PLUG.x0, PLUG.y0, PLUG.z0], [PLUG.x1, PLUG.y1, PLUG.z1], { tone: "mid" }, { r: 0.4 });
const plugTop = 192.1 - (148 - 135.24) * ((192.1 - 181.1) / (177.24 - 135.24));
box("loom.plug.bracket", [-51, 148, PLUG.z1], [-47.6, 149, plugTop], { tone: "lo" });
cable("loom", [[PLUG.x1, 148.8, 175], [46, 148.8, 175], [52, 146, 168], [52, 146, 146], [52, 20, 146], [28, 7.8, 146], plugAt(146)], 1.3, { tone: "mid", bend: 6, shells: [false, true], rings: { pitch: 1.1, twist: 0.8, cross: true, fade: [0.1, 0.55] } });
const LANES = { up: [-49, 175.8], left: [-53.6, 175], right: [-44.4, 175], down: [-50.8, 174.2] };
STICK_DATA.switches.forEach((sw) => {
  const tab = G.add3(sw.c, G.mul3(CPN, -2));
  const [x, z] = LANES[sw.id];
  const route = sw.id === "down" ? [tab, [tab[0], tab[1], z], [x, tab[1] - 3, z], [x, PLUG.y1, z]] : [tab, [tab[0], tab[1], z], [x, PLUG.y1, z]];
  cable(`wire.stick.${sw.id}`, route, 0.25, { tone: "lo", bend: 1.2 });
});
const SWX = (SWITCH.x0 + SWITCH.x1) / 2;
cable("wire.coin", [[SWX + 0.9, SW_Y, SWITCH.z0], [SWX + 0.9, SW_Y, 12.6], [50, SW_Y, 12.6], [50, 66, 12.6], [50, 66, 128], [50, 20, 128], [28, 7.8, 128], plugAt(128)], 0.5, { tone: "mid", bend: 3, shells: [false, true] });
cable("wire.meter", [[METER.x0 + 3, METER.y0 + 5, METER.z1], [METER.x0 + 3, METER.y0 + 5, 40], [SWX - 0.9, SW_Y - 1, 52], [SWX - 0.9, SW_Y, SWITCH.z0]], 0.4, { tone: "lo", bend: 3 });
cable("cable.power", [[-1, 43, 12.4], [-1, 50, 12.4], [44, 50, 12.4], [54, 62, 12.4], [54, 62, 136], [54, 20, 136], [28, 7.8, 136], plugAt(136)], 0.7, { tone: "mid", bend: 5, shells: [false, true] });
cable("wire.service", [[SV.x, 30, SV.z0 + 1.6], [56.6, 30, SV.z0 + 1.6], [56.6, 13, SV.z0 + 1.6], [56.6, 13, 141.4], [30, 7.8, 141.4], plugAt(141.4)], 0.4, { tone: "lo", bend: 3, shells: [false, true] });
cable("cable.mains", [[PW.x - 6, 26, 45.5], [46, 26, 45.5], [46, 26, 15], [30, 26, 15]], 0.6, { tone: "lo", bend: 4 });
cable("cord.ac", [[-30, 6, 22], [-30, 13, 22]], 0.9, { tone: "mid", bend: 3 });

const SPK_BACK = G.add3(SPK_C, G.mul3(SPK_N, 3.8));
cable("cable.speaker", [SPK_BACK, G.add3(SPK_BACK, G.mul3(SPK_N, 0.6)), [22, 104, 316.6], [22, 7.8, 316.6], [22, 7.8, 158], plugAt(158)], 0.4, { tone: "lo", bend: 3 });
const VPLUG = chPoint(59, 39, 3);
cable("cable.video", [[px(9), 7.9, PCB.z1 + 2.4], [px(9), 7.9, 184], [-52, 36, 192], [-52, VPLUG[1], VPLUG[2]], VPLUG], 0.6, { tone: "mid", bend: 5 });
const flyTop = chPoint(51, 18, 4);
const capTop = G.add3(anodeAt, G.mul3(anodeN, 2.2));
cable("lead.anode", [flyTop, G.add3(flyTop, G.mul3(bd, -6)), G.add3(capTop, G.mul3(anodeN, 6)), capTop], 0.8, { tone: "hi", bend: 5 });
const NPLUG = chPoint(59.5, 20.5, 3);
cable("cable.neck", [monPoint(-4, 4, 91), monPoint(-4, 4, 94), monPoint(-30, 16, 94), G.add3(NPLUG, [-5, 0, 0]), NPLUG], 0.45, { tone: "lo", bend: 3 });

const OPEN = BEZEL_OPEN;
const FACE_OFF = -5;
const bezelPt = (u, w, off = 0) => G.add3(G.add3(G.add3(C3, [u, 0, 0]), G.mul3(bd, w)), G.mul3(NB, off));
const bezelMatrix = (off) => planeMatrix(bezelPt(0, 0, off), [1, 0, 0], bd);
const roundRect = (u0, u1, w0, w1, r, steps = 5) => k.roundedPlan({ x: u0, y: w0, w: u1 - u0, d: w1 - w0, r }, steps);
const openRing = roundRect(-OPEN.u, OPEN.u, -OPEN.w, OPEN.w, OPEN.r);
const flat = (ring) => `M${ring.map(([u, w]) => `${f2(u)} ${f2(w)}`).join("L")}Z`;
const HALF_LEN = 57;
const bezelFace = `M-${XI} ${-HALF_LEN}H${XI}V${HALF_LEN}H-${XI}Z` + flat([...openRing].reverse());
const recess = [];
{
  const ring = openRing;
  const n = ring.length;
  for (let i = 0; i < n; i++) {
    const [a, b] = [ring[i], ring[(i + 1) % n]];
    const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
    const inward = G.unit3(G.add3(G.mul3([1, 0, 0], -mid[0]), G.mul3(bd, -mid[1])));
    const along = G.unit3(G.add3(G.mul3([1, 0, 0], b[0] - a[0]), G.mul3(bd, b[1] - a[1])));
    let normal = G.cross3(along, NB);
    if (G.dot3(normal, inward) < 0) normal = G.mul3(normal, -1);
    if (G.dot3(normal, V) <= 0) continue;
    const quad = [bezelPt(a[0], a[1], -1), bezelPt(b[0], b[1], -1), bezelPt(b[0], b[1], FACE_OFF), bezelPt(a[0], a[1], FACE_OFF)];
    recess.push({ tone: G.toneOf(normal, P), d: path3(quad, true) });
  }
}
const openClip = path3(openRing.map(([u, w]) => bezelPt(u, w, -1)), true);
const faceClip = path3(roundRect(-33, 33, -41, 41, 7).map(([u, w]) => bezelPt(u, w, FACE_OFF)), true);
const screenSvg = SCREEN.svg();
const bezelItem = [
  `<clipPath id="ac-open"><path d="${openClip}"/></clipPath>`,
  `<clipPath id="ac-face"><path d="${faceClip}"/></clipPath>`,
  `<g clip-path="url(#ac-open)">`,
  `<path class="ac-glass" data-part="glass" d="${faceClip}"/>`,
  `<g clip-path="url(#ac-face)"><g transform="${bezelMatrix(FACE_OFF)}"><g data-part="screen" class="ac-screen">${screenSvg}</g></g></g>`,
  ...recess.map((q) => `<path class="iso-shade" data-shade="${Math.min(3, q.tone)}" d="${q.d}"/>`),
  `</g>`,
  `<g transform="${bezelMatrix(-1)}"><path class="ac-bezel" d="${bezelFace}"/><path class="iso-line" data-tone="lo" d="${flat(openRing)}"/>`,
  `<path class="iso-line" data-tone="faint" d="${flat(roundRect(-OPEN.u - 3, OPEN.u + 3, -OPEN.w - 3, OPEN.w + 3, OPEN.r + 3))}"/>`,
  `<path class="iso-line" data-tone="lo" data-free="" d="M-18 46H18M-12 49.6H12M-15 53.2H15"/></g>`,
].join("");
const bezelSection = [[0, -HALF_LEN], [0, HALF_LEN], [-1.6, HALF_LEN], [-1.6, -HALF_LEN]];
const BZF = G.frameOf(bezelPt(0, 0, 0), [1, 0, 0], NB, bd);
{
  const shape = record(A.prism(BZF, [[-1.6, -HALF_LEN], [0, -HALF_LEN], [0, HALF_LEN], [-1.6, HALF_LEN]], -XI, XI, { name: "bezel" }));
  const glassEdge = S(prismPaths(BZF, bezelSection, -XI, XI), { tone: "lo", flat: true });
  put("bezel", bezelPt(0, 0, 0), `<g class="ac-bezel-solid">${glassEdge}</g>${bezelItem}<path class="ac-glare" d="${path3([bezelPt(-24, -50, 0.05), bezelPt(-6, -50, 0.05), bezelPt(26, 50, 0.05), bezelPt(8, 50, 0.05)], true)}"/>`, [shape], { bias: 1 });
}
A.settle(R, P);
R.items.sort((a, b) => a.key - b.key);

const indexOf = (name) => R.items.findIndex((item) => item.name === name);
const slots = new Map();
slots.set(Math.max(indexOf("chute.floor"), indexOf("chute.far")), `<g data-part="coin-chute"></g><g data-part="wire">${Ln(path3([WIRE.from, G.add3(WIRE.from, [WIRE.length, 0, 0])]), { tone: "hi", free: "end" })}</g>`);
slots.set(Math.max(indexOf("cash.floor"), indexOf("cash.back"), indexOf("cash.far")), `<g data-part="pile"></g><g data-part="coin-fall"></g>`);
const groups = R.items.map((item, index) => `<g class="it" data-name="${item.name}"${item.attrs ?? ""}>${item.svg}</g>${slots.get(index) ?? ""}`);

const trayF = G.frameOf([TRAY.x, TRAY.y, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const tray = S(G.lathe([[0, 0], [0, 9], [1.6, 9], [1.6, 7.6], [0.8, 7.2]], trayF, P, { steps: 48 }), { tone: "lo" });
const stack = Array.from({ length: STACK }, (_, i) => `<g data-stack="${i}">${PARTS.coin([TRAY.x, TRAY.y, stackTop(i) + 0.2], [0, 0, 1], 0, { lit: false })}</g>`).join("");

const LABEL_TEXT =
  "An upright arcade cabinet on a bench with its left side panel off: a CRT turned on its side behind the tilted glass, a marquee lamp and speaker above, a control panel with a joystick and four buttons whose microswitches hang below it, a coin door with two coin mechs, a coin switch, a meter and a cash box, the game board on the far wall, and the power supply on the floor. A stack of quarters sits on a tray on the bench.";
const svg = k.figureSvg({
  width: W,
  height: H,
  label: LABEL_TEXT,
  body: [...back, `<g data-part="tray">${tray}${stack}</g>`, `<g class="ac-cab">${groups.join("")}</g>`, `<g data-part="coin-fly"></g>`, `<g data-part="pulse"></g>`],
});

const DATA = {
  P,
  V,
  stick: STICK_DATA,
  tray: { x: TRAY.x, y: TRAY.y, top: stackTop(STACK - 1) + 0.2, step: COIN_STEP, count: STACK },
  slot: { x: ENTRIES[0], y: FY + 3.8, z: (SLOT_Z[0] + SLOT_Z[1]) / 2 },
  mech: MECH(ENTRIES[0]),
  chute: { ...CHUTE, slope: CHUTE.slope },
  wire: WIRE,
  box: BOX,
  meterFace: METER_FACE,
  screen: { off: FACE_OFF },
  routes: Object.fromEntries(["wire.coin", "cable.video", "cable.power", "loom"].map((name) => [name, R.routes.find((r) => r.name === name).points.map((p) => at(p).map(f2))])),
  meterMatrix: planeMatrix(METER_FACE.o, METER_FACE.ex, METER_FACE.ey),
};

const CSS = readFileSync(join(HERE, "arcade-cabinet.css"), "utf8");
const latheSource = readFileSync(join(HERE, "..", "..", "kit", "lathe.mjs"), "utf8")
  .replace(/^import .*$/m, "")
  .replace(/^export /gm, "");
const latheScript = `const G = (() => {${latheSource}\nreturn { viewOf, scaleOf, frameAlong, frameOf, pointOf, disc, sphereOf, prismOf, circleOf, arcOf, dot3, add3, sub3, mul3, unit3, cross3, len3, lightOf, toneOf, radialOf };})();\n`;
const partsScript = readFileSync(join(HERE, "parts.mjs"), "utf8").replace(/^export /gm, "");
const screenScript = readFileSync(join(HERE, "screen.mjs"), "utf8").replace(/^export /gm, "");
const LIVE = readFileSync(join(HERE, "live.js"), "utf8").replace("__DATA__", JSON.stringify(DATA));

const body =
  `<style>${CSS}</style>` +
  k.plateHtml({
    fig: "Fig 4",
    title: "Upright cabinet, side off",
    hint: "C or click the quarters · ← → · space",
    readout: "off · 0 credits",
    keys: [
      { mark: "lit", label: "The picture: drawn in the plane of the CRT behind the tilted glass" },
      { mark: "raised", label: "Coin path: slot, mech, switch wire, meter, cash box" },
      { mark: "edge", label: "Harness from the joystick, buttons and coin switch to the board's edge connector" },
    ],
    caption:
      "An upright cabinet in the style of 1981, 178 cm tall, with the left side panel off. Drop a quarter (24.26 mm) in the left slot: the mech checks it and lets it fall 27 cm past the coin-switch wire into the open cash box. The switch pulses the meter and the board's coin line, and on this machine it also latches the power relay, so the first coin wakes the cabinet: the marquee strikes, the CRT's heater warms for about two seconds, the board runs its RAM and ROM self-test and the attract mode starts. The monitor lies on its side for a vertical game, 224 × 288 pixels on a 15.734 kHz raster, so its scan lines run up and down the glass and it collapses to a vertical line when switched off. Move the stick to see the actuator close a microswitch under the panel.",
    body: `<div class="iso-stage ac-stage" tabindex="0" role="group" aria-label="Arcade cabinet. Press C or click the quarters to insert a coin; left and right arrows move the joystick; space presses the fire button; Escape switches the cabinet off.">${svg}<span class="ac-sr" role="status" aria-live="polite" data-status></span></div>`,
  });

const page = k.pageHtml({ title: "Arcade cabinet", theme: THEME, body, script: kitScript() + latheScript + partsScript + screenScript + LIVE, width: 650 });
const name = THEME === "light" ? "arcade-cabinet-light.html" : "arcade-cabinet.html";
writeFileSync(join(HERE, name), page);
console.log("wrote", name, `${(page.length / 1024).toFixed(0)} KB`, `${R.items.length} items`, `scale ${KS.toFixed(3)}`, `${CABLES.length} cables`);

A.auditOrExit(R, P, {});
```

## examples/arcade-cabinet/live.js

```js

const D = __DATA__;
const P = D.P;
const K = { iso, pathOf, solidSvg, lineSvg, dotsSvg };
const PARTS = makeParts(K, G, P, { stick: D.stick });
const stage = document.querySelector(".iso-stage");
const svg = stage.querySelector("svg");
const $ = (sel) => svg.querySelector(sel);
const $$ = (sel) => [...svg.querySelectorAll(sel)];
const at = (p) => iso(p, P);
const readout = document.querySelector("[data-readout]");
const status = document.querySelector("[data-status]");
const still = matchMedia("(prefers-reduced-motion: reduce)");

const el = {
  upper: $('[data-part="stick-upper"]'),
  lower: $('[data-part="stick-lower"]'),
  wire: $('[data-part="wire"]'),
  fall: $('[data-part="coin-fall"]'),
  chute: $('[data-part="coin-chute"]'),
  fly: $('[data-part="coin-fly"]'),
  pile: $('[data-part="pile"]'),
  screen: $('[data-part="screen"]'),
  raster: $('[data-part="raster"]'),
  dot: $('[data-part="dot"]'),
  test: $('[data-mode="test"]'),
  attract: $('[data-mode="attract"]'),
  steps: $$("[data-step]"),
  formation: $('[data-part="formation"]'),
  aliens: $$("[data-alien]"),
  ship: $('[data-part="ship"]'),
  shot: $('[data-part="shot"]'),
  boom: $('[data-part="boom"]'),
  score: $('[data-part="score"]'),
  insert: $('[data-part="insert"]'),
  start: $('[data-part="start"]'),
  credit: $('[data-part="credit"]'),
  meter: $('[data-part="meter"]'),
  glass: $('[data-part="glass"]'),
  stack: $$("[data-stack]"),
  fire: $('[data-button="fire"]'),
  p1: $('[data-button="p1"]'),
  lamps: $$(".ac-lamp"),
  leds: $$(".ac-led"),
  marquee: $$('[data-name="marquee.tube"], [data-name="marquee.plexi"], .ac-marquee'),
};
const routes = {};
for (const g of $$("[data-route]")) (routes[g.dataset.route] ??= []).push(g);
for (const list of Object.values(routes)) list.sort((a, b) => a.dataset.chunk - b.dataset.chunk);

const smooth = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};
const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const norm = (v) => {
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
  return [v[0] / l, v[1] / l, v[2] / l];
};

const GRAV = 1962;
const FLY = 0.9;
const INSERT = 0.34;
const MECH = 0.42;
const DROP_FROM = D.mech.z0 + PARTS.COIN_R;
const FLOOR = D.box.z0 + 0.7 + PARTS.COIN_T / 2;
const HEATER = 2.2;
const TEST = [1.0, 1.7, 2.3, 2.8];
const ATTRACT_AT = 3.6;
const HKHZ = 15.734;

const state = {
  power: 0,
  powerAt: -99,
  offAt: -99,
  credits: 0,
  meter: 4127,
  coins: [],
  pile: 0,
  stack: D.tray.count,
  stick: { x: 0, target: 0 },
  fire: 0,
  fireHeld: 0,
  pulses: [],
  wire: { x: 0, v: 0, push: 0 },
  game: null,
  clock: 0,
};

function newGame() {
  return { step: 0, dir: 1, offset: 0, frame: 0, alive: D_FORMATION.map(() => true), shipX: 112, shot: null, boom: null, score: 0, cool: 1.2, userAt: -99, respawn: 0 };
}
const D_FORMATION = SCREEN.FORMATION;

const STACK_AT = (n) => [D.tray.x, D.tray.y, 1.6 + (n - 1) * D.tray.step + 0.2];
const SLOT_OUT = [D.slot.x, D.slot.y + 9, D.slot.z];
const SLOT_IN = [D.slot.x, D.slot.y - 12, D.slot.z];
const CH = D.chute;
const SN = 0.5;
const CS = Math.sqrt(3) / 2;
const chuteZ = (y) => CH.z0 - CH.slope * (CH.y0 - y);
const ROLL_FROM = D.mech.y0;
const ROLL_A = (2 / 3) * GRAV * SN;
const ROLL_LEN = (ROLL_FROM - CH.y1) / CS;
const T_ROLL = Math.sqrt((2 * ROLL_LEN) / ROLL_A);
const V_END = ROLL_A * T_ROLL;
const END = [D.slot.x, CH.y1 - PARTS.COIN_R * SN, chuteZ(CH.y1) + PARTS.COIN_R * CS];
const EDGE_Z = FLOOR + PARTS.COIN_R;
const T_DROP = (-V_END * SN + Math.sqrt((V_END * SN) ** 2 + 2 * GRAV * (END[2] - EDGE_Z))) / GRAV;
const LAND_Y = END[1] - V_END * CS * T_DROP;
function insertCoin() {
  if (state.stack <= 0) state.stack = D.tray.count;
  const from = STACK_AT(state.stack);
  state.stack--;
  showStack();
  state.coins.push({ t: 0, from, landAt: pileSpot(state.pile + state.coins.length), switched: false, landed: false });
  run();
}
function pileSpot(i) {
  const a = i * 2.39996;
  const r = Math.min(5, 1.1 * Math.sqrt(i));
  const lo = D.box.y0 + 0.7 + PARTS.COIN_R + 0.3;
  const hi = D.box.y1 - 0.7 - PARTS.COIN_R - 0.3;
  return [D.slot.x + Math.cos(a) * r * 1.2, Math.max(lo, Math.min(hi, LAND_Y + Math.sin(a) * r)), FLOOR + Math.floor(i / 12) * PARTS.COIN_T];
}
function coinPose(c) {
  let t = c.t;
  if (t < FLY) {
    const s = smooth(0, 1, t / FLY);
    const mid = [(c.from[0] + SLOT_OUT[0]) / 2, (c.from[1] + SLOT_OUT[1]) / 2, Math.max(c.from[2], SLOT_OUT[2]) + 26];
    const a = lerp3(c.from, mid, s);
    const b = lerp3(mid, SLOT_OUT, s);
    const p = lerp3(a, b, s);
    const turn = smooth(0.15, 0.85, t / FLY);
    return { layer: "fly", p, axis: norm(lerp3([0, 0, 1], [1, 0, 0], turn)), spin: s * 7 };
  }
  t -= FLY;
  if (t < INSERT) return { layer: "fly", clip: "door", p: lerp3(SLOT_OUT, SLOT_IN, smooth(0, 1, t / INSERT)), axis: [1, 0, 0], spin: 7 };
  t -= INSERT;
  if (t < MECH) return { layer: "none", stage: "mech" };
  t -= MECH;
  if (t < T_ROLL) {
    const s = 0.5 * ROLL_A * t * t;
    const y = ROLL_FROM - s * CS;
    const p = [D.slot.x, y - PARTS.COIN_R * SN, chuteZ(y) + PARTS.COIN_R * CS];
    return { layer: "chute", clip: p[1] + PARTS.COIN_R > D.mech.y0 ? "mech" : null, p, axis: [1, 0, 0], spin: 7 + s / PARTS.COIN_R, stage: "roll" };
  }
  t -= T_ROLL;
  const land = c.landAt;
  if (t < T_DROP) {
    const share = t / T_DROP;
    const p = [END[0] + (land[0] - END[0]) * share, END[1] - V_END * CS * t, END[2] - V_END * SN * t - 0.5 * GRAV * t * t];
    p[1] = END[1] + (land[1] - END[1]) * share;
    return { layer: "fall", p, axis: [1, 0, 0], spin: 7 + ROLL_LEN / PARTS.COIN_R + t * 20, stage: "drop" };
  }
  const tl = t - T_DROP;
  const tip = smooth(0, 0.16, tl);
  const lie = [land[0], land[1], land[2] + PARTS.COIN_R * (1 - tip)];
  return { layer: "fall", p: lie, axis: norm(lerp3([1, 0, 0], [0, 0, 1], tip)), spin: 7, settled: tl > 0.22, stage: "drop" };
}

const CLIP_DOOR = (() => {
  const a = at([D.slot.x, D.slot.y, -100]);
  const b = at([D.slot.x, D.slot.y, 300]);
  return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] + 400} ${b[1]}L${a[0] + 400} ${a[1]}Z`;
})();
const CLIP_MECH = (() => {
  const a = at([D.slot.x, D.mech.y0, -100]);
  const b = at([D.slot.x, D.mech.y0, 300]);
  return `M${a[0]} ${a[1]}L${b[0]} ${b[1]}L${b[0] - 400} ${b[1]}L${a[0] - 400} ${a[1]}Z`;
})();
svg.querySelector("defs") || svg.insertAdjacentHTML("afterbegin", "<defs></defs>");
svg.querySelector("defs").insertAdjacentHTML("beforeend", `<clipPath id="ac-clip-door"><path d="${CLIP_DOOR}"/></clipPath><clipPath id="ac-clip-mech"><path d="${CLIP_MECH}"/></clipPath>`);

function drawCoins() {
  const out = { fly: "", chute: "", fall: "" };
  for (const c of state.coins) {
    const pose = coinPose(c);
    if (pose.layer === "none") continue;
    const coin = PARTS.coin(pose.p, pose.axis, pose.spin);
    out[pose.layer] += pose.clip ? `<g clip-path="url(#ac-clip-${pose.clip})">${coin}</g>` : coin;
  }
  for (const key of ["fly", "chute", "fall"]) if (el[key].innerHTML !== out[key]) el[key].innerHTML = out[key];
}
function showStack() {
  el.stack.forEach((g, i) => (g.style.display = i < state.stack ? "" : "none"));
}
function drawPile() {
  let out = "";
  for (let i = 0; i < state.pile; i++) out += PARTS.coin(pileSpot(i), [0, 0, 1], i * 1.7, { lit: false });
  el.pile.innerHTML = out;
}

function wireStep(dt, push) {
  const w = state.wire;
  if (push > w.x) {
    w.x = push;
    w.v = 0;
  } else {
    const k = 900;
    const c = 9;
    for (let i = 0; i < 4; i++) {
      const h = dt / 4;
      w.v += (-k * w.x - c * w.v) * h;
      w.x += w.v * h;
    }
    if (Math.abs(w.x) < 1e-3 && Math.abs(w.v) < 1e-2) (w.x = 0), (w.v = 0);
  }
  const from = D.wire.from;
  const angle = Math.atan2(w.x, D.wire.length);
  const tip = [from[0] + D.wire.length * Math.cos(angle), from[1], from[2] - D.wire.length * Math.sin(angle)];
  el.wire.innerHTML = lineSvg(pathOf([at(from), at(tip)]), { tone: w.x > 0.3 ? "lit" : "hi", free: "end" });
  return w.x !== 0;
}
function wirePush() {
  let push = 0;
  const wz = D.wire.from[2];
  const wy = D.wire.from[1];
  for (const c of state.coins) {
    const pose = coinPose(c);
    if (pose.stage !== "roll") continue;
    const dy = wy - pose.p[1];
    if (Math.abs(dy) >= PARTS.COIN_R) continue;
    const low = pose.p[2] - Math.sqrt(PARTS.COIN_R ** 2 - dy * dy);
    push = Math.max(push, Math.min(1.2, wz - low));
  }
  return Math.max(0, push) * (D.wire.length / (D.slot.x - D.wire.from[0]));
}
function pulse(route, delay = 0, seconds = 0.5) {
  if (!routes[route]) return;
  state.pulses.push({ route, t: -delay, seconds });
}
function drawPulses(dt) {
  const hot = new Set();
  state.pulses = state.pulses.filter((p) => {
    p.t += dt;
    if (p.t < 0) return true;
    const list = routes[p.route];
    const at = (p.t / p.seconds) * (list.length + 1);
    list.forEach((g, i) => Math.abs(i - at + 0.5) < 1.1 && hot.add(g));
    return p.t < p.seconds * 1.25;
  });
  for (const list of Object.values(routes)) for (const g of list) g.toggleAttribute("data-hot", hot.has(g));
  return state.pulses.length > 0;
}

function setPower(on) {
  if (!!on === !!state.power) return;
  state.power = on ? 1 : 0;
  if (on) {
    state.powerAt = state.clock;
    state.game = newGame();
  } else {
    state.offAt = state.clock;
    state.credits = 0;
  }
  el.leds.forEach((g) => g.toggleAttribute("data-on", !!on));
  el.lamps.forEach((g) => g.toggleAttribute("data-on", !!on));
  status.textContent = on ? "Coin accepted. The cabinet wakes and the CRT warms up." : "Switched off.";
  run();
}
function marqueeLevel(t) {
  if (!state.power) return 0;
  if (t < 0.06) return 0;
  if (t < 0.12) return 1;
  if (t < 0.24) return 0;
  if (t < 0.3) return 1;
  if (t < 0.42) return 0.2;
  return 1;
}
function picture() {
  const t = state.clock - state.powerAt;
  const off = state.clock - state.offAt;
  let bright = 0;
  let sx = 1;
  let sy = 1;
  let dot = 0;
  let mode = "none";
  if (state.power) {
    bright = smooth(0.9, HEATER + 0.4, t);
    mode = t < ATTRACT_AT ? (t > 0.9 ? "test" : "none") : t < ATTRACT_AT + 0.08 ? "none" : "attract";
  } else if (off < 1.2 && state.offAt > 0) {
    bright = 1;
    mode = state.lastMode;
    if (off < 0.09) sx = 1 - 0.99 * smooth(0, 0.09, off);
    else if (off < 0.26) (sx = 0.01), (sy = 1 - smooth(0.09, 0.26, off));
    else (sx = 0), (sy = 0);
    dot = off < 0.2 ? 0 : 1 - smooth(0.26, 1.1, off);
  }
  if (state.power) state.lastMode = mode;
  el.raster.setAttribute("transform", `scale(${Math.max(sx, 0.001).toFixed(3)} ${Math.max(sy, 0.004).toFixed(3)})`);
  el.raster.style.opacity = (sx === 0 ? 0 : bright).toFixed(3);
  el.dot.style.opacity = dot.toFixed(3);
  el.test.style.display = mode === "test" ? "" : "none";
  el.attract.style.display = mode === "attract" ? "" : "none";
  el.steps.forEach((s, i) => (s.style.display = t > TEST[i] ? "" : "none"));
  el.glass.toggleAttribute("data-on", bright > 0.05 && sx > 0);
  const m = marqueeLevel(t);
  el.marquee.forEach((g) => g.toggleAttribute("data-on", m > 0.5));
  return { mode, t, busy: state.power ? t < ATTRACT_AT + 0.2 : off < 1.2 };
}

const SHOT_SPEED = 220;
function gameStep(dt, mode) {
  const g = state.game;
  if (!g || mode !== "attract") return;
  g.step += dt;
  if (g.step > 0.32) {
    g.step = 0;
    g.offset += g.dir * 2;
    if (Math.abs(g.offset) >= 16) g.dir = -g.dir;
    g.frame = 1 - g.frame;
    el.formation.setAttribute("transform", `translate(${g.offset} 0)`);
    for (const a of el.aliens) {
      a.children[0].style.display = g.frame ? "none" : "";
      a.children[1].style.display = g.frame ? "" : "none";
    }
  }
  const user = state.clock - g.userAt < 3;
  if (user) g.shipX += state.stick.x * 420 * dt;
  else {
    const live = D_FORMATION.map((a, i) => (g.alive[i] ? a.x + 5 + g.offset : null)).filter((x) => x !== null);
    const target = live.length ? live[Math.floor((state.clock * 0.37) % live.length)] : 112;
    g.shipX += Math.max(-50 * dt, Math.min(50 * dt, target - g.shipX));
  }
  g.shipX = Math.max(14, Math.min(210, g.shipX));
  el.ship.setAttribute("transform", `translate(${g.shipX.toFixed(1)} ${SCREEN.SHIP_Y})`);
  g.cool -= dt;
  if (!g.shot && ((state.fire > 0 && user) || (!user && g.cool < 0))) {
    g.shot = { x: Math.round(g.shipX), y: SCREEN.SHIP_Y - 6 };
    g.cool = 0.9 + Math.random() * 0.8;
  }
  if (g.shot) {
    g.shot.y -= SHOT_SPEED * dt;
    let hit = -1;
    D_FORMATION.forEach((a, i) => {
      if (!g.alive[i] || hit >= 0) return;
      const x = a.x + g.offset;
      if (g.shot.x >= x && g.shot.x <= x + 10 && g.shot.y <= a.y + 8 && g.shot.y + 5 >= a.y) hit = i;
    });
    if (hit >= 0) {
      g.alive[hit] = false;
      el.aliens[hit].style.display = "none";
      const a = D_FORMATION[hit];
      g.boom = { x: a.x + g.offset, y: a.y, t: 0.22 };
      g.score += a.kind === "eye" ? 30 : 10;
      el.score.setAttribute("d", SCREEN.text(String(g.score).padStart(5, "0"), 16, 20));
      g.shot = null;
    } else if (g.shot.y < 30) g.shot = null;
  }
  el.shot.style.display = g.shot ? "" : "none";
  if (g.shot) el.shot.setAttribute("transform", `translate(${g.shot.x} ${g.shot.y.toFixed(1)})`);
  if (g.boom) {
    g.boom.t -= dt;
    el.boom.style.display = g.boom.t > 0 ? "" : "none";
    el.boom.setAttribute("transform", `translate(${g.boom.x} ${g.boom.y})`);
    if (g.boom.t <= 0) g.boom = null;
  }
  if (g.alive.every((a) => !a)) {
    g.respawn += dt;
    if (g.respawn > 1.2) {
      g.respawn = 0;
      g.alive = g.alive.map(() => true);
      el.aliens.forEach((a) => (a.style.display = ""));
    }
  }
  const blink = Math.floor(state.clock / 0.5) % 2 === 0;
  el.insert.style.display = !state.credits && blink ? "" : "none";
  el.start.style.display = state.credits && blink ? "" : "none";
  el.p1?.toggleAttribute("data-blink", state.credits > 0 && blink);
}
let creditShown = -1;
function showCredit() {
  if (creditShown === state.credits) return;
  creditShown = state.credits;
  el.credit.setAttribute("d", SCREEN.text(`CREDIT ${Math.min(9, state.credits)}`, 120, 274));
}

const TILT = 0.21;
let stickShown = null;
function stickStep(dt, calm) {
  const s = state.stick;
  const before = s.x;
  s.x = calm ? s.target : s.x + (s.target - s.x) * (1 - Math.exp(-dt / 0.05));
  if (Math.abs(s.x - s.target) < 1e-3) s.x = s.target;
  const key = s.x.toFixed(3);
  if (key !== stickShown) {
    stickShown = key;
    el.upper.innerHTML = PARTS.stickUpper(s.x * TILT, 0);
    el.lower.innerHTML = PARTS.stickLower(s.x * TILT, 0);
  }
  return s.x !== s.target || before !== s.x;
}
const firePush = axisVector(P, D.stick.nc.map((v) => -v * 1.1));
function fireStep(dt) {
  state.fireHeld = Math.max(0, state.fireHeld - dt);
  const want = state.fireHeld > 0 ? 1 : 0;
  state.fire += (want - state.fire) * (1 - Math.exp(-dt / 0.03));
  if (Math.abs(want - state.fire) < 0.01) state.fire = want;
  el.fire.setAttribute("transform", state.fire ? `translate(${(firePush[0] * state.fire).toFixed(2)} ${(firePush[1] * state.fire).toFixed(2)})` : "");
  el.fire.toggleAttribute("data-down", state.fire > 0.5);
  return state.fire !== want || state.fireHeld > 0;
}

let phase = "";
function say(mode, t) {
  let text;
  const coin = state.coins.find((c) => !c.landed);
  if (coin) {
    const tt = coin.t;
    text = tt < FLY ? "coin · 24.26 mm quarter" : tt < FLY + INSERT ? "coin · into the slot" : tt < FLY + INSERT + MECH ? "mech · checking the coin" : coin.switched ? `coin switch · meter ${String(state.meter).padStart(6, "0")}` : "coin · rolling down the chute";
  } else if (!state.power) text = state.clock - state.offAt < 1.2 && state.offAt > 0 ? "off · picture folds to a line" : `off · ${state.pile ? "insert another coin" : "insert coin"}`;
  else if (mode === "test") text = t < TEST[1] ? "self-test" : t < TEST[2] ? "self-test · RAM OK" : "self-test · RAM OK · ROM OK";
  else if (mode !== "attract") text = `warming · heater ${Math.min(HEATER, t).toFixed(1)} s`;
  else {
    const dir = state.stick.target < 0 ? " · stick ←" : state.stick.target > 0 ? " · stick →" : state.fireHeld > 0 ? " · fire" : "";
    text = `credit ${state.credits} · ${HKHZ} kHz${dir}`;
  }
  if (readout.textContent !== text) readout.textContent = text;
  const now = !state.power ? "off" : mode;
  if (now !== phase) {
    phase = now;
    if (now === "attract") status.textContent = `Attract mode, ${state.credits} credit${state.credits === 1 ? "" : "s"}.`;
    if (now === "test") status.textContent = "Self-test.";
  }
}

let frame = 0;
let last = 0;
let visible = false;
let touring = !still.matches;
let tourClock = 0;
let idle = 0;
const TOUR = [
  [1.4, () => insertCoin()],
  [9.5, () => press("right", 0.5)],
  [10.4, () => press("left", 0.6)],
  [11.4, () => fireButton()],
  [12.6, () => press("right", 0.35)],
  [13.3, () => fireButton()],
  [15.2, () => insertCoin()],
  [24, () => setPower(0)],
  [27.5, null],
];
let tourIndex = 0;
function tourStep(dt) {
  tourClock += dt;
  while (tourIndex < TOUR.length && tourClock >= TOUR[tourIndex][0]) {
    const action = TOUR[tourIndex][1];
    tourIndex++;
    if (action) action();
    else {
      tourClock = 0;
      tourIndex = 0;
      state.pile = 0;
      state.stack = D.tray.count;
      showStack();
      drawPile();
    }
  }
}
let releaseTimer = 0;
function press(dir, seconds) {
  state.stick.target = dir === "left" ? -1 : 1;
  if (state.game) state.game.userAt = state.clock;
  clearTimeout(releaseTimer);
  releaseTimer = setTimeout(() => ((state.stick.target = 0), run()), seconds * 1000);
  run();
}
function fireButton() {
  state.fireHeld = 0.16;
  if (state.game) state.game.userAt = state.clock;
  run();
}

function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  const calm = still.matches;
  state.clock += dt;
  if (touring) tourStep(dt);
  for (const c of state.coins) {
    c.t += calm ? 10 : dt;
    const pose = coinPose(c);
    if (!c.switched && pose.stage === "roll" && pose.p[1] < D.wire.from[1]) {
      c.switched = true;
      state.meter++;
      el.meter.textContent = String(state.meter).padStart(6, "0");
      pulse("wire.coin", 0, 0.45);
      pulse("wire.meter", 0, 0.25);
      if (!state.power) {
        setTimeout(() => {
          setPower(1);
          pulse("cable.power", 0, 0.5);
          pulse("cable.video", 0.5, 0.5);
        }, calm ? 0 : 420);
      }
      setTimeout(() => {
        state.credits++;
        showCredit();
        run();
      }, calm ? 0 : 450);
    }
    if (!c.landed && (pose.settled || calm)) {
      c.landed = true;
      state.pile++;
      drawPile();
    }
  }
  state.coins = state.coins.filter((c) => !c.landed);
  drawCoins();
  const wireBusy = wireStep(dt, wirePush());
  const pulsing = drawPulses(dt);
  const { mode, t, busy } = picture();
  gameStep(dt, mode);
  showCredit();
  const sticking = stickStep(dt, calm);
  const firing = fireStep(dt);
  say(mode, t);
  const alive = touring || state.coins.length || wireBusy || pulsing || busy || sticking || firing || (state.power && !calm);
  frame = visible && alive ? requestAnimationFrame(tick) : 0;
}
function run() {
  if (!frame && visible) {
    last = 0;
    frame = requestAnimationFrame(tick);
  }
}
function takeOver() {
  if (touring) {
    touring = false;
  }
  clearTimeout(idle);
  idle = setTimeout(() => {
    if (still.matches) return;
    touring = true;
    tourClock = state.power ? 4 : 0;
    tourIndex = TOUR.findIndex(([t]) => t > tourClock);
    if (state.power) tourIndex = TOUR.findIndex(([t]) => t > 9);
    tourClock = state.power ? 9 : 0;
    run();
  }, 12000);
}
new IntersectionObserver((entries) => {
  visible = entries[entries.length - 1].isIntersecting;
  if (visible) run();
}, { rootMargin: "120px 0px" }).observe(stage);

stage.addEventListener("keydown", (event) => {
  if (event.metaKey || event.ctrlKey || event.altKey) return;
  const key = event.key;
  if (key === "c" || key === "C" || key === "5") {
    event.preventDefault();
    takeOver();
    insertCoin();
  } else if (key === "ArrowLeft" || key === "ArrowRight") {
    event.preventDefault();
    takeOver();
    state.stick.target = key === "ArrowLeft" ? -1 : 1;
    if (state.game) state.game.userAt = state.clock;
    clearTimeout(releaseTimer);
    run();
  } else if (key === " " || key === "Enter") {
    event.preventDefault();
    takeOver();
    state.fireHeld = 0.5;
    if (state.game) state.game.userAt = state.clock;
    run();
  } else if (key === "Escape") {
    event.preventDefault();
    takeOver();
    setPower(0);
  }
});
stage.addEventListener("keyup", (event) => {
  if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
    state.stick.target = 0;
    run();
  }
  if (event.key === " " || event.key === "Enter") state.fireHeld = Math.min(state.fireHeld, 0.06);
});
svg.addEventListener("click", (event) => {
  const hit = event.target.closest('[data-part="tray"], [data-name^="door"]');
  if (hit) {
    takeOver();
    insertCoin();
    stage.focus({ preventScroll: true });
  }
});

showStack();
drawPile();
showCredit();
wireStep(0, 0);
picture();
say("none", 0);
if (still.matches) touring = false;
```

## examples/arcade-cabinet/parts.mjs

```js

export function makeParts(k, G, P, D) {
  const V = G.viewOf(P);
  const at = (p) => k.iso(p, P);
  const S = (paths, style) => k.solidSvg(paths, style);
  const Ln = (d, style) => k.lineSvg(d, style);
  const rot = (v, axis, angle) => {
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    const d = G.dot3(axis, v);
    const x = G.cross3(axis, v);
    return [v[0] * c + x[0] * s + axis[0] * d * (1 - c), v[1] * c + x[1] * s + axis[1] * d * (1 - c), v[2] * c + x[2] * s + axis[2] * d * (1 - c)];
  };
  const J = D.stick;
  const UP = J.nc;
  const ALONG = J.dc;
  const X = [1, 0, 0];
  const axisOf = (tx, tv) => G.unit3(rot(rot(UP, ALONG, tx), X, -tv));
  const pivot = G.add3(J.o, G.mul3(UP, J.pivot));
  const depth = (p) => G.dot3(p, V);

  function stickUpper(tx = 0, tv = 0) {
    const a = axisOf(tx, tv);
    const F = G.frameAlong(pivot, a);
    const t = -J.pivot / G.dot3(a, UP);
    const cross = G.add3(pivot, G.mul3(a, t));
    const W = G.frameOf(cross, UP, X, ALONG);
    const washer = S(G.disc(0, 0.4, 4.4, W, P, { steps: 40 }), { tone: "mid" }) + Ln(G.circleOf(W, 0.4, 1.3, P, 20), { tone: "faint" });
    const shaft = S(G.disc(t + 0.4, t + 8.95, 0.85, F, P, { steps: 16 }), { tone: "mid" });
    const ballAt = G.add3(pivot, G.mul3(a, t + 12.4));
    const ball = S(G.sphereOf(ballAt, 3.5, P, { steps: 40 }), { tone: "hi", lit: true });
    return washer + shaft + ball;
  }

  function stickLower(tx = 0, tv = 0) {
    const a = axisOf(tx, tv);
    const F = G.frameAlong(pivot, a);
    const parts = [];
    parts.push({ d: depth(G.add3(pivot, G.mul3(a, -5))), svg: S(G.disc(-9.4, -0.6, 0.85, F, P, { steps: 16 }), { tone: "mid" }) });
    const act = S(G.disc(-8.6, -4.6, 2.4, F, P, { steps: 32, bevel: 0.3 }), { tone: "hi" }) + Ln(G.arcOf(F, -6.6, 2.4, P), { tone: "faint" });
    parts.push({ d: depth(G.add3(pivot, G.mul3(a, -6.6))) + 0.01, svg: act });
    parts.push({ d: depth(G.add3(pivot, G.mul3(a, -9.6))), svg: S(G.disc(-10, -9.4, 1.5, F, P, { steps: 20 }), { tone: "lo" }) });
    const closed = switchesClosed(tx, tv);
    for (const sw of J.switches) {
      const on = closed.includes(sw.id);
      const body = G.frameOf(sw.c, UP, sw.out, G.cross3(UP, sw.out));
      const poly = [[-0.65, -2], [0.65, -2], [0.65, 2], [-0.65, 2]];
      const box = S(G.prismOf(body, poly, -1, 1, P), { tone: "mid" });
      const lever0 = G.add3(sw.c, G.add3(G.mul3(sw.out, -0.65), G.mul3(G.cross3(UP, sw.out), 1.6)));
      const tip = G.add3(sw.c, G.add3(G.mul3(sw.out, on ? -0.85 : -1.45), G.mul3(G.cross3(UP, sw.out), -1.2)));
      const lever = Ln(k.pathOf([at(lever0), at(tip)]), { tone: on ? "lit" : "hi" });
      const tabs = [-1.1, 0, 1.1].map((o) => G.add3(sw.c, G.add3(G.mul3(UP, -1), G.mul3(G.cross3(UP, sw.out), o))));
      const tabSvg = tabs.map((p) => S(G.prismOf(G.frameOf(p, UP, sw.out, G.cross3(UP, sw.out)), [[-0.3, -0.35], [0.3, -0.35], [0.3, 0.35], [-0.3, 0.35]], -1, 0, P), { tone: "lo" })).join("");
      parts.push({ d: depth(sw.c), svg: `<g class="ac-switch"${on ? " data-on" : ""}>${tabSvg}${box}${lever}</g>` });
    }
    parts.sort((p, q) => p.d - q.d);
    return parts.map((p) => p.svg).join("");
  }

  function switchesClosed(tx, tv) {
    const out = [];
    const limit = 0.12;
    if (tx > limit) out.push("left");
    if (tx < -limit) out.push("right");
    if (tv > limit) out.push("up");
    if (tv < -limit) out.push("down");
    return out;
  }

  const COIN_R = 2.426;
  const COIN_T = 0.35;
  function coin(centre, axis, spin = 0, { lit = true } = {}) {
    const F0 = G.frameAlong(centre, axis);
    const F = { o: F0.o, a: F0.a, u: rot(F0.u, F0.a, spin), v: rot(F0.v, F0.a, spin) };
    const face = G.dot3(F.a, V) >= 0 ? COIN_T / 2 : -COIN_T / 2;
    const body = S(G.disc(-COIN_T / 2, COIN_T / 2, COIN_R, F, P, { steps: 36 }), { tone: "hi", lit });
    const facing = Math.abs(G.dot3(F.a, V));
    let marks = "";
    if (facing > 0.2) {
      marks += Ln(G.circleOf(F, face, COIN_R - 0.35, P, 28), { tone: "lo" });
      const p0 = G.pointOf(F, face, 0.3, 0);
      const p1 = G.pointOf(F, face, 1.5, 0);
      marks += Ln(k.pathOf([at(p0), at(p1)]), { tone: "lo", free: true });
    }
    return body + marks;
  }

  return { stickUpper, stickLower, switchesClosed, coin, axisOf, COIN_R, COIN_T };
}
```

## examples/arcade-cabinet/screen.mjs

```js
export const SCREEN = (() => {
  const W = 224;
  const H = 288;
  const FONT = {
    A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
    C: ["01110", "10001", "10000", "10000", "10000", "10001", "01110"],
    D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
    E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
    F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
    H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
    I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
    K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
    L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
    M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
    N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
    O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
    P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
    R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
    S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
    T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
    U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
    Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
    Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
    0: ["01110", "10011", "10101", "10101", "10101", "11001", "01110"],
    1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
    2: ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
    3: ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
    4: ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
    5: ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
    6: ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
    7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
    8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
    9: ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
    "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
    ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"],
    " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
  };
  const SPRITES = {
    jellyA: ["0001111000", "0011111100", "0110110110", "1111111111", "1111111111", "0010110100", "0100000010", "0010000100"],
    jellyB: ["0001111000", "0011111100", "0110110110", "1111111111", "1111111111", "0010110100", "0001001000", "0010000100"],
    eyeA: ["0011110000", "0111111000", "1101101100", "1111111100", "0110011000", "1100001100"].map((r) => r.slice(0, 8)),
    eyeB: ["0011110000", "0111111000", "1101101100", "1111111100", "0110011000", "0011110000"].map((r) => r.slice(0, 8)),
    ship: ["00000100000", "00001110000", "00001110000", "01111111110", "11111111111", "11011111011", "10000100001"],
    boom: ["1000100010", "0100100100", "0010001000", "1100000011", "0010001000", "0100100100", "1000100010"],
  };
  const n = (v) => Math.round(v * 100) / 100;
  function bitmap(rows, x, y, s = 1) {
    let d = "";
    rows.forEach((row, r) => {
      let c = 0;
      while (c < row.length) {
        if (row[c] === "1") {
          let e = c;
          while (e < row.length && row[e] === "1") e++;
          d += `M${n(x + c * s)} ${n(y + r * s)}h${n((e - c) * s)}v${n(s)}h${n(-(e - c) * s)}z`;
          c = e;
        } else c++;
      }
    });
    return d;
  }
  function text(str, x, y, s = 1, adv = 8) {
    return [...String(str)].map((ch, i) => bitmap(FONT[ch] ?? FONT[" "], x + i * adv * s, y, s)).join("");
  }
  const centred = (str, s = 1, adv = 8) => n((W - (String(str).length * adv - (adv - 5)) * s) / 2);

  const FORMATION = [];
  for (let row = 0; row < 3; row++) for (let col = 0; col < 6; col++) FORMATION.push({ row, col, x: 34 + col * 26, y: 92 + row * 18, kind: row === 0 ? "eye" : "jelly" });
  const SHIP_Y = 250;
  const px = (d, extra = "") => `<path class="ac-px"${extra} d="${d}"/>`;

  function svg() {
    const out = [];
    const hatch = [];
    for (let x = 0; x <= W; x += 16) hatch.push(`M${Math.min(x, W - 1)} 0h1v${H}h-1z`);
    for (let y = 0; y <= H; y += 16) hatch.push(`M0 ${Math.min(y, H - 1)}h${W}v1h${-W}z`);
    out.push(`<g data-mode="test" style="display:none">`);
    out.push(`<path class="ac-px ac-dim" d="${hatch.join("")}"/>`);
    out.push(px(text("SELF TEST", centred("SELF TEST"), 56), ` data-step="0"`));
    out.push(px(text("RAM OK", 56, 104), ` data-step="1"`));
    out.push(px(text("ROM OK", 56, 120), ` data-step="2"`));
    out.push(px(text("Z80 3.072 MHZ", 56, 152), ` data-step="3"`));
    out.push(`</g>`);
    out.push(`<g data-mode="attract" style="display:none">`);
    out.push(px(text("1UP", 24, 8) + text("HI-SCORE", 80, 8) + text("2UP", 176, 8)));
    out.push(px(text("00000", 16, 20), ` data-part="score"`));
    out.push(px(text("12500", 92, 20)));
    out.push(px(text("ANATOMY", centred("ANATOMY", 2), 44, 2)));
    out.push(`<g data-part="formation">`);
    FORMATION.forEach((a, i) => {
      const [fa, fb] = a.kind === "eye" ? [SPRITES.eyeA, SPRITES.eyeB] : [SPRITES.jellyA, SPRITES.jellyB];
      const ox = a.kind === "eye" ? 1 : 0;
      out.push(`<g data-alien="${i}">${px(bitmap(fa, a.x + ox, a.y), ' data-frame="a"')}${px(bitmap(fb, a.x + ox, a.y), ' data-frame="b" style="display:none"')}</g>`);
    });
    out.push(`</g>`);
    out.push(px(bitmap(SPRITES.boom, 0, 0), ` data-part="boom" style="display:none"`));
    out.push(px("M0 0h1v5h-1z", ` data-part="shot" style="display:none"`));
    out.push(px(bitmap(SPRITES.ship, -5.5, 0), ` data-part="ship" transform="translate(112 ${SHIP_Y})"`));
    out.push(px(`M8 ${SHIP_Y + 12}h${W - 16}v1h${-(W - 16)}z`));
    out.push(px(text("INSERT COIN", centred("INSERT COIN"), 200), ` data-part="insert"`));
    out.push(px(text("PUSH START", centred("PUSH START"), 200), ` data-part="start" style="display:none"`));
    out.push(px(text("CREDIT 0", 120, 274), ` data-part="credit"`));
    out.push(`</g>`);
    const k = 58 / W;
    const scan = [];
    for (let i = 0; i <= 112; i++) scan.push(`M${n(-29 + i * 0.518)} -37.3V37.3`);
    return [
      `<g data-part="raster"><g transform="translate(-29 -37.3) scale(${n(k * 10000) / 10000})">${out.join("")}</g>`,
      `<path class="ac-scan" d="${scan.join("")}"/></g>`,
      `<ellipse class="ac-dot" data-part="dot" cx="0" cy="0" rx="1.2" ry="1.2" style="opacity:0"/>`,
    ].join("");
  }
  return { W, H, FONT, SPRITES, FORMATION, SHIP_Y, bitmap, text, centred, svg };
})();
```

## examples/desk-computer

```

```

## examples/desk-computer/build.mjs

```js
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import { kitScript } from "../../scripts/inline-kit.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const theme = process.argv.includes("--light") ? "light" : "dark";

const WIDTH = 600;
const HEIGHT = 440;

const PLATE = { x: -100, y: -84, w: 210, d: 242, r: 10 };
const PLATE_H = 10;
const FOOT_H = 5;

const CASE = { x: -62, y: -62, w: 124, d: 118, r: 8 };
const FRONT = CASE.y + CASE.d;
const SIDE = CASE.x + CASE.w;
const PLINTH_H = 5;
const TOP = 170;
const SEAM = FRONT - 12;

const BEZEL = { x0: -48, x1: 48, z0: 68, z1: 158, r: 6, depth: 1.6 };
const CRT = { x0: -40, x1: 40, z0: 77, z1: 150, r: 8, depth: 5 };
const GLASS_Y = FRONT - BEZEL.depth - CRT.depth;
const CRT_MID = (CRT.z0 + CRT.z1) / 2;
const SLOT = { x0: 8, x1: 46, z0: 45, z1: 50.5, r: 2.4, depth: 4 };

const KB = { x: -76, y: 84, w: 152, d: 58, r: 4 };
const KB_Z = 1.6;
const KB_TOP = 8.6;
const U = 9.2;
const KEYS_X = -U * 7.5;
const KEYS_Y = 88.5;

const ROWS = [
  [["`", 1], ["1", 1], ["2", 1], ["3", 1], ["4", 1], ["5", 1], ["6", 1], ["7", 1], ["8", 1], ["9", 1], ["0", 1], ["-", 1], ["=", 1], ["Backspace", 2]],
  [["Tab", 1.5], ["q", 1], ["w", 1], ["e", 1], ["r", 1], ["t", 1], ["y", 1], ["u", 1], ["i", 1], ["o", 1], ["p", 1], ["[", 1], ["]", 1], ["\\", 1.5]],
  [["CapsLock", 1.75], ["a", 1], ["s", 1], ["d", 1], ["f", 1], ["g", 1], ["h", 1], ["j", 1], ["k", 1], ["l", 1], [";", 1], ["'", 1], ["Enter", 2.25]],
  [["Shift", 2.25], ["z", 1], ["x", 1], ["c", 1], ["v", 1], ["b", 1], ["n", 1], ["m", 1], [",", 1], [".", 1], ["/", 1], ["ShiftRight", 2.75]],
  [["Alt", 1.5], ["Meta", 1.5], [" ", 9], ["NumpadEnter", 1.5], ["AltRight", 1.5]],
];
const ROW_H = [5.6, 5.2, 4.8, 4.4, 4];

const PORT = { y: 28, z: 9 };
const COIL = { x: 80, z: 3.6, r: 3.2, from: 44, to: 84, turns: 17 };

const P = k.fitProjection(
  [...k.boxCorners(PLATE, -PLATE_H - FOOT_H, 0), ...k.boxCorners(CASE, 0, TOP)],
  WIDTH,
  HEIGHT,
  { pad: 30, azimuth: 56 },
);

const at = (point) => k.iso(point, P);
const S = (paths, style) => k.solidSvg(paths, style);
const L = (d, style) => k.lineSvg(d, style);
const D = (points, style) => k.dotsSvg(points, style);
const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));
const planeOf = (y) => k.sideMatrix([0, y], 0, P, "left");
const flat = (x0, z0, x1, z1, r, steps = 6) => k.pathOf(k.roundedPlan({ x: x0, y: z0, w: x1 - x0, d: z1 - z0, r }, steps).map(([u, z]) => [u, -z]), true);
const onFront = (points, y = FRONT) => k.pathOf(points.map(([x, z]) => at([x, y, z])));
const sideSlot = (y, z0, z1, half = 0.8) => k.pathOf(k.roundedPlan({ x: y - half, y: z0, w: half * 2, d: z1 - z0, r: half }, 4).map(([u, z]) => at([SIDE, u, z])), true);

let clips = 0;
function recess({ x0, x1, z0, z1, r, depth }, y, back = "", backClass = "pc-well", inside = "") {
  const id = `pc-clip-${clips++}`;
  const ring = k.roundedPlan({ x: x0, y: z0, w: x1 - x0, d: z1 - z0, r }, 6);
  const cx = (x0 + x1) / 2;
  const cz = (z0 + z1) / 2;
  const { sinA } = k.cameraOf(P);
  const tops = [];
  const sides = [[], [], [], []];
  ring.forEach(([ax, az], index) => {
    const [bx, bz] = ring[(index + 1) % ring.length];
    const length = Math.hypot(bx - ax, bz - az) || 1;
    let [nx, nz] = [(bz - az) / length, -(bx - ax) / length];
    if (nx * (cx - (ax + bx) / 2) + nz * (cz - (az + bz) / 2) < 0) [nx, nz] = [-nx, -nz];
    if (nx <= 0 && nz <= 0) return;
    const quad = k.pathOf([at([ax, y, az]), at([bx, y, bz]), at([bx, y - depth, bz]), at([ax, y - depth, az])], true);
    if (nz >= nx) tops.push(quad);
    else sides[Math.min(3, Math.max(0, Math.floor(((nx * -sinA + 1) / 2) * 4)))].push(quad);
  });
  const shape = flat(x0, z0, x1, z1, r);
  return [
    `<clipPath id="${id}"><path transform="${planeOf(y)}" d="${shape}"/></clipPath>`,
    `<g clip-path="url(#${id})">`,
    `<path class="iso-top" d="${tops.join("")}"/>`,
    ...sides.map((d, shade) => (d.length ? `<path class="iso-shade" data-shade="${shade}" d="${d.join("")}"/>` : "")),
    `<g transform="${planeOf(y - depth)}"><path class="${backClass}" d="${shape}"/>${back}<path class="iso-line" data-tone="faint" d="${shape}"/></g>`,
    inside,
    `</g>`,
    `<g transform="${planeOf(y)}"><path class="iso-line" data-tone="lo" d="${shape}"/></g>`,
  ].join("");
}

function plate() {
  const out = [`<path class="iso-halo" d="${k.haloOf(PLATE, -PLATE_H - FOOT_H, PLATE_H, P)}"/>`];
  for (const [x, y] of byDepth(k.corners(PLATE, 22))) {
    out.push(S(k.cylinder(x, y, 7, -PLATE_H - FOOT_H, 1.6, P, 28), { tone: "lo" }));
    out.push(S(k.cylinder(x, y, 5.4, -PLATE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" }));
  }
  out.push(S(k.slabOf(PLATE, -PLATE_H, PLATE_H, P, 8, 1.6), { tone: "mid" }));
  out.push(L(k.planOutline(k.insetPlan(PLATE, 5), 0, P), { tone: "lo" }));
  const screws = k.corners(PLATE, 10);
  out.push(L(screws.map(([x, y]) => k.ring(x, y, 2.2, 0, P, 16)).join(""), { tone: "lo" }));
  out.push(D(screws.map(([x, y]) => at([x, y, 0])), { size: 0.5 }));
  const label = { x: -88, y: 144, w: 40, d: 9, r: 1.2 };
  out.push(S(k.slabOf(label, 0, 0.8, P, 3), { tone: "lo", crease: "none" }));
  out.push(L([30, 22].map((w, i) => k.lineOnTop([label.x + 4, label.y + 3 + i * 3], [label.x + 4 + w, label.y + 3 + i * 3], 0.8, P)).join(""), { tone: "lo", free: true }));
  out.push(D([at([label.x + label.w - 3, label.y + label.d / 2, 0.8])], { size: 0.45 }));
  return out.join("");
}

function screen() {
  const H = 19;
  const left = -H * 0.61;
  const base = CRT_MID + 2;
  const glyph = (points) => k.pathOf(points.map(([gx, gy]) => [left + gx * H, -(base + gy * H)]), true);
  const logo =
    glyph([[0, 0], [0.37, 1], [0.55, 1], [0.92, 0], [0.71, 0], [0.63, 0.23], [0.29, 0.23], [0.21, 0]]) +
    glyph([[0.35, 0.41], [0.57, 0.41], [0.46, 0.72]]) +
    glyph([[0.68, 1], [0.89, 1], [1.26, 0], [1.05, 0]]);
  const lines = [];
  for (let z = CRT.z0 + 1.2; z < CRT.z1; z += 1.6) lines.push(`M${CRT.x0} ${(-z).toFixed(1)}H${CRT.x1}`);
  const glow = flat(-30, CRT.z0 + 8, 30, CRT.z1 - 8, 26, 10);
  const textX = CRT.x0 + 8;
  const textZ = CRT.z0 + 14;
  const crt = [
    `<path class="pc-glow" d="${glow}"/>`,
    `<path class="pc-scan" d="${lines.join("")}"/>`,
    `<path class="pc-logo" d="${logo}"/>`,
    `<text class="pc-text" data-part="text" x="${textX}" y="${-textZ}" font-size="5.2">&gt; </text>`,
    `<rect class="pc-cursor" data-part="cursor" x="${textX + 6.6}" y="${-textZ - 4.2}" width="3" height="5"/>`,
  ].join("");
  const back = [
    `<g data-part="crt" transform="">${crt}</g>`,
    `<path class="pc-beam" data-part="beam" d="M${CRT.x0 + 4} ${-CRT_MID}H${CRT.x1 - 4}" style="opacity:0"/>`,
    `<circle class="pc-dot" data-part="dot" cx="0" cy="${-CRT_MID}" r="1.4" style="opacity:0"/>`,
    `<path class="iso-line" data-tone="lo" d="M${CRT.x0 + 6} ${-(CRT.z1 - 16)}Q${CRT.x0 + 7} ${-(CRT.z1 - 7)} ${CRT.x0 + 16} ${-(CRT.z1 - 6)}"/>`,
  ].join("");
  return recess(BEZEL, FRONT, "", "pc-bezel", recess(CRT, FRONT - BEZEL.depth, back, "pc-glass"));
}

function computer() {
  const out = [`<path class="iso-halo" d="${k.haloOf(CASE, 0, 8, P)}"/>`];
  out.push(S(k.slabOf(k.insetPlan(CASE, 3), 0, PLINTH_H, P, 8), { tone: "lo" }));
  out.push(S(k.slabOf(CASE, PLINTH_H, TOP - PLINTH_H, P, 8, 1.6), { tone: "mid" }));
  const handle = { x: -42, y: CASE.y + 7, w: 84, d: 11, r: 5.5 };
  out.push(L(k.planOutline(handle, TOP, P, 8), { tone: "lo" }));
  out.push(L(k.planOutline(k.insetPlan(handle, 2.2), TOP, P, 8), { tone: "faint" }));
  out.push(L(Array.from({ length: 7 }, (_, i) => k.planOutline({ x: -34, y: CASE.y + 27.3 + i * 3.4, w: 68, d: 1.4, r: 0.7 }, TOP, P, 4)).join(""), { tone: "faint" }));
  out.push(L(k.lineOnTop([CASE.x, SEAM], [SIDE, SEAM], TOP, P) + k.segment([SIDE, SEAM, TOP], [SIDE, SEAM, PLINTH_H], P), { tone: "lo" }));
  const rowOf = (from, to) => Array.from({ length: Math.floor((to - from) / 3.2 + 1e-9) + 1 }, (_, i) => from + i * 3.2);
  out.push(L(rowOf(CASE.y + 10, CASE.y + 52).map((y) => sideSlot(y, TOP - 48, TOP - 14)).join(""), { tone: "faint" }));
  out.push(L(rowOf(CASE.y + 10, CASE.y + 40).map((y) => sideSlot(y, 20, 30)).join(""), { tone: "faint" }));
  out.push(`<g transform="${planeOf(FRONT)}"><path class="iso-line" data-tone="faint" d="${flat(CASE.x + 4, PLINTH_H + 4, SIDE - 4, TOP - 4, 5)}"/></g>`);
  out.push(screen());
  out.push(recess(SLOT, FRONT, `<path class="iso-line" data-tone="lo" d="M${SLOT.x0} ${-(SLOT.z0 + 1.6)}H${SLOT.x1}"/>`, "pc-dark"));
  out.push(D([at([SLOT.x1 + 4, FRONT, SLOT.z0 - 1])], { size: 0.5, tone: "mid" }));
  out.push(`<g transform="${planeOf(FRONT)}">${L(Array.from({ length: 9 }, (_, i) => flat(-46.7 + i * 3, 42, -45.3 + i * 3, 54, 0.7, 4)).join(""), { tone: "lo" })}</g>`);
  out.push(`<g data-part="led">${D([at([-46, FRONT, 36])], { size: 0.8, tone: "hi" })}</g>`);
  out.push(rocker());
  out.push(port());
  return `<g class="pc-computer" data-part="computer">${out.join("")}</g>`;
}

function rocker() {
  const y = CASE.y + 8;
  const out = [S(k.slabOf({ x: SIDE, y, w: 1.2, d: 12, r: 1 }, 12, 15, P, 3), { tone: "mid" })];
  const half = (z, proud) => S(k.slabOf({ x: SIDE + 1.2, y: y + 1.5, w: proud ? 2.4 : 0.8, d: 9, r: 0.6 }, z, 5.6, P, 2), { tone: "mid" });
  out.push(`<g data-rocker="on">${half(13, true)}${half(20.4, false)}</g>`);
  out.push(`<g data-rocker="off" style="display:none">${half(20.4, true)}${half(13, false)}</g>`);
  return out.join("");
}

function port() {
  const out = [];
  out.push(S(k.slabOf({ x: SIDE - 0.4, y: PORT.y - 1, w: 1.4, d: 10, r: 1 }, PORT.z - 1, 9, P, 3), { tone: "lo" }));
  out.push(S(k.slabOf({ x: SIDE + 1, y: PORT.y, w: 4.4, d: 8, r: 1 }, PORT.z, 7, P, 3, 0.4), { tone: "mid" }));
  out.push(S(k.slabOf({ x: SIDE + 5.4, y: PORT.y + 2, w: 5, d: 4, r: 1.6 }, PORT.z + 1.5, 4, P, 4), { tone: "mid" }));
  out.push(D([at([SIDE + 3.2, PORT.y + 1.6, PORT.z + 7]), at([SIDE + 3.2, PORT.y + 6.4, PORT.z + 7])], { size: 0.4 }));
  return out.join("");
}

const keyList = [];
function keyboard() {
  const out = [`<path class="iso-halo" d="${k.haloOf(KB, 0, 4, P)}"/>`];
  for (const [x, y] of byDepth(k.corners(KB, 8))) out.push(S(k.cylinder(x, y, 2.8, 0, KB_Z, P, 16), { tone: "lo" }));
  out.push(S(k.slabOf(KB, KB_Z, KB_TOP - KB_Z, P, 6, 1), { tone: "mid" }));
  const well = { x: KEYS_X - 1.4, y: KEYS_Y - 1.4, w: U * 15 + 2.8, d: U * 5 + 2.8, r: 2 };
  out.push(L(k.planOutline(well, KB_TOP, P, 4), { tone: "lo" }));
  out.push(D([at([KB.x + 4, KB.y + 2.4, KB_TOP]), at([KB.x + KB.w - 4, KB.y + 2.4, KB_TOP])], { size: 0.45 }));
  ROWS.forEach((row, r) => {
    let x = KEYS_X;
    for (const [code, width] of row) {
      const plan = { x: x + 0.7, y: KEYS_Y + r * U + 0.7, w: width * U - 1.4, d: U - 1.4, r: 1.4 };
      const h = ROW_H[r];
      const index = keyList.length;
      keyList.push(code);
      const dish = code === " " ? k.planOutline(k.insetPlan(plan, 1.8), KB_TOP + h, P, 3) : k.planOutline(k.insetPlan(plan, 1.6), KB_TOP + h, P, 3);
      out.push(`<g class="pc-key" data-key="${index}">${S(k.slabOf(plan, KB_TOP, h, P, 3, 0.4), { tone: "mid", crease: "faint" })}${L(dish, { tone: "faint" })}</g>`);
      x += width * U;
    }
  });
  out.push(S(k.slabOf({ x: KB.x + KB.w, y: 90, w: 4, d: 4, r: 1.5 }, 2.5, 4, P, 4), { tone: "mid" }));
  return out.join("");
}

function cable() {
  const out = [];
  const curve = (points) => k.pathOf(points.map((point) => at(point)));
  const start = [COIL.x + COIL.r, COIL.from, COIL.z];
  const end = [COIL.x + COIL.r, COIL.to, COIL.z];
  out.push(`<path class="iso-line pc-cord" data-tone="mid" d="${curve([[SIDE + 10.4, PORT.y + 4, PORT.z + 3.5], [SIDE + 14, PORT.y + 6, PORT.z + 2.4], [SIDE + 17, PORT.y + 9.5, PORT.z - 1], [SIDE + 19.6, PORT.y + 13, COIL.z + 1.6], start])}"/>`);
  const spring = k.coil(COIL.from, COIL.to, [COIL.x, COIL.z], COIL.r, COIL.turns, "y", P);
  out.push(L(spring.back, { tone: "lo" }), L(spring.front, { tone: "mid" }));
  out.push(`<path class="iso-line pc-cord" data-tone="mid" d="${curve([end, [COIL.x + 2.6, COIL.to + 3, COIL.z + 0.2], [KB.x + KB.w + 6.4, 91.4, 4.4], [KB.x + KB.w + 4, 92, 4.5]])}"/>`);
  return out.join("");
}

const svg = k.figureSvg({
  width: WIDTH,
  height: HEIGHT,
  label:
    "An all-in-one desk computer in the spirit of 1984 on a bench plate: a tall case with a CRT set into its front showing the Anthropic mark and a typing prompt, a floppy slot and speaker grille below, vents and a rocker switch on the side, and a keyboard joined to a side port by a coiled cord.",
  body: [plate(), computer(), keyboard(), cable()],
});

const PHRASES = ["hello, claude", "what is a crt?", "draw it isometric"];

const live = `
const P = ${JSON.stringify(P)};
const KEYS = ${JSON.stringify(keyList)};
const PHRASES = ${JSON.stringify(PHRASES)};
const MAX_CHARS = 20, CRT_MID = ${CRT_MID}, TEXT_X = ${CRT.x0 + 8};
const SHIFTED = { "~": "\`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/" };
const stage = document.querySelector(".iso-stage");
const svg = stage.querySelector("svg");
const keyEls = KEYS.map((_, i) => svg.querySelector('[data-key="' + i + '"]'));
const textEl = svg.querySelector('[data-part="text"]');
const cursor = svg.querySelector('[data-part="cursor"]');
const crt = svg.querySelector('[data-part="crt"]');
const beam = svg.querySelector('[data-part="beam"]');
const dot = svg.querySelector('[data-part="dot"]');
const glass = svg.querySelector(".pc-glass");
const led = svg.querySelector('[data-part="led"]');
const rockerOn = svg.querySelector('[data-rocker="on"]');
const rockerOff = svg.querySelector('[data-rocker="off"]');
const readout = document.querySelector("[data-readout]");
const status = document.querySelector("[data-status]");
const still = matchMedia("(prefers-reduced-motion: reduce)");
const DOWN = 2.2;
const down = axisVector(P, [0, 0, -1]);

const keyState = KEYS.map(() => ({ at: 0, held: 0 }));
const active = new Set();
let line = "", lastKey = "", power = 1, powerClock = 9, keysTyped = 0;
let frame = 0, previous = 0, visible = false, touring = !still.matches, idleTimer = 0;
let tour = { phrase: 0, step: "type", index: 0, wait: 1.4 };

const indexOf = (code) => KEYS.indexOf(code);
function press(code, hold = 0.09) {
  const i = indexOf(code);
  if (i < 0) return;
  keyState[i].held = hold;
  active.add(i);
  run();
}
function type(char) {
  if (!power) return;
  const lower = char.toLowerCase();
  const code = SHIFTED[char] || lower;
  if (indexOf(code) < 0) return;
  if (SHIFTED[char] || char !== lower) press("Shift", 0.14);
  press(code);
  keysTyped++;
  lastKey = char === " " ? "space" : char;
  if (line.length < MAX_CHARS) line += char;
  write();
}
function erase() { if (!power) return; press("Backspace"); lastKey = "backspace"; line = line.slice(0, -1); write(); }
function enter() { if (!power) return; press("Enter"); lastKey = "return"; line = ""; write(); }
function write() {
  textEl.textContent = "> " + line;
  cursor.setAttribute("x", String(TEXT_X + textEl.getComputedTextLength() + 0.4));
  const text = power ? "on · " + line.length + " chars" + (lastKey ? " · key " + lastKey : "") : "off";
  if (readout.textContent !== text) readout.textContent = text;
}
function setPower(next) {
  if (next === power) return;
  power = next;
  powerClock = still.matches ? 9 : 0;
  rockerOn.style.display = power ? "" : "none";
  rockerOff.style.display = power ? "none" : "";
  led.style.opacity = power ? "" : "0.15";
  status.textContent = power ? "Computer on." : "Computer off.";
  write();
  run();
}
const ease = (t) => 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3);
function crtFrame() {
  const t = powerClock;
  let sx = 1, sy = 1, beamAt = 0, dotAt = 0, lit = power;
  if (power) {
    if (t < 0.14) { sx = ease(t / 0.14); sy = 0.012; beamAt = 1; }
    else if (t < 0.36) { sy = 0.012 + 0.988 * ease((t - 0.14) / 0.22); beamAt = 1 - ease((t - 0.14) / 0.22); }
  } else {
    if (t < 0.1) { sy = 1 - 0.988 * ease(t / 0.1); beamAt = ease(t / 0.1); lit = 1; }
    else if (t < 0.26) { sy = 0.012; sx = 1 - ease((t - 0.1) / 0.16); beamAt = 1; lit = 1; }
    else { sx = 0; sy = 0; dotAt = Math.max(0, 1 - (t - 0.26) / 0.5); }
  }
  crt.setAttribute("transform", "translate(0 " + -CRT_MID + ") scale(" + sx.toFixed(3) + " " + sy.toFixed(3) + ") translate(0 " + CRT_MID + ")");
  beam.setAttribute("transform", "translate(0 " + -CRT_MID + ") scale(" + Math.max(sx, 0.001).toFixed(3) + " 1) translate(0 " + CRT_MID + ")");
  beam.style.opacity = beamAt.toFixed(2);
  dot.style.opacity = dotAt.toFixed(2);
  glass.toggleAttribute("data-on", !!lit);
  return t > 0.8;
}
function tourStep(dt) {
  tour.wait -= dt;
  if (tour.wait > 0) return;
  const phrase = PHRASES[tour.phrase];
  if (tour.step === "type") {
    if (tour.index < phrase.length) { type(phrase[tour.index++]); tour.wait = 0.09 + Math.random() * 0.11 + (phrase[tour.index - 1] === " " ? 0.08 : 0); }
    else { tour.step = "erase"; tour.wait = 2.4; }
  } else if (tour.step === "erase") {
    if (line.length) { erase(); tour.wait = 0.055; }
    else if (tour.phrase === PHRASES.length - 1) { tour.step = "off"; tour.wait = 0.9; }
    else { tour.phrase++; tour.index = 0; tour.step = "type"; tour.wait = 0.7; }
  } else if (tour.step === "off") { setPower(0); tour.step = "on"; tour.wait = 1.6; }
  else if (tour.step === "on") { setPower(1); tour = { phrase: 0, step: "type", index: 0, wait: 1.4 }; }
}
function tick(now) {
  const dt = previous ? Math.min((now - previous) / 1000, 1 / 30) : 1 / 60;
  previous = now;
  const calm = still.matches;
  if (touring) tourStep(dt);
  powerClock += dt;
  const settledCrt = crtFrame();
  for (const i of active) {
    const s = keyState[i];
    s.held = Math.max(0, s.held - dt);
    const want = s.held > 0 ? DOWN : 0;
    s.at = calm ? want : s.at + (want - s.at) * (1 - Math.exp(-dt / (want ? 0.025 : 0.07)));
    if (Math.abs(want - s.at) < 0.01) s.at = want;
    keyEls[i].setAttribute("transform", "translate(" + (down[0] * s.at).toFixed(2) + " " + (down[1] * s.at).toFixed(2) + ")");
    keyEls[i].toggleAttribute("data-down", s.at > 0.6);
    if (!s.held && s.at === 0) active.delete(i);
  }
  const busy = touring || active.size || !settledCrt;
  frame = visible && busy ? requestAnimationFrame(tick) : 0;
}
function run() { if (!frame && visible) { previous = 0; frame = requestAnimationFrame(tick); } }
function takeOver(switching = false) {
  if (touring) { touring = false; tour = { phrase: 0, step: "type", index: 0, wait: 1.4 }; line = ""; if (!switching) setPower(1); write(); }
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => { if (still.matches) return; touring = true; line = ""; setPower(1); write(); run(); }, 9000);
}
new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage);
stage.addEventListener("keydown", (event) => {
  if (event.metaKey || event.ctrlKey || event.altKey) return;
  const key = event.key;
  if (key === "Tab") return;
  takeOver(key === "Escape");
  if (key === "Escape") { event.preventDefault(); setPower(power ? 0 : 1); return; }
  if (key === "Backspace") { event.preventDefault(); erase(); return; }
  if (key === "Enter") { event.preventDefault(); enter(); return; }
  if (key === "Shift") { press(event.code === "ShiftRight" ? "ShiftRight" : "Shift", 0.14); return; }
  if (key.length === 1) { event.preventDefault(); type(key); }
});
svg.addEventListener("click", (event) => {
  const key = event.target.closest("[data-key]");
  if (key) {
    takeOver();
    const code = KEYS[Number(key.dataset.key)];
    if (code.length === 1) type(code);
    else if (code === "Backspace") erase();
    else if (code === "Enter" || code === "NumpadEnter") enter();
    else press(code);
    stage.focus({ preventScroll: true });
    return;
  }
  if (event.target.closest('[data-part="computer"]')) { takeOver(true); setPower(power ? 0 : 1); stage.focus({ preventScroll: true }); }
});
write();
crtFrame();
`;

const css = `<style>
.pc-well{fill:var(--anatomy-shade-1)}
.pc-bezel{fill:var(--anatomy-top)}
.pc-dark{fill:var(--anatomy-shade-0)}
.pc-glass{fill:var(--anatomy-shade-0);transition:fill .5s var(--anatomy-ease)}
.pc-glass[data-on]{fill:var(--anatomy-lit-shade)}
.pc-glow{fill:var(--anatomy-lit);opacity:.07;filter:blur(6px)}
.pc-scan{fill:none;stroke:var(--anatomy-faint);stroke-width:.5px;vector-effect:non-scaling-stroke;opacity:.7}
.pc-logo{fill:var(--anatomy-lit);fill-rule:evenodd}
.pc-text{fill:var(--anatomy-lit);font-family:var(--anatomy-mono);letter-spacing:.02em}
.pc-cursor{fill:var(--anatomy-lit);animation:pc-blink 1.06s steps(1,end) infinite}
.pc-beam{fill:none;stroke:var(--anatomy-lit);stroke-width:1.4px;vector-effect:non-scaling-stroke;stroke-linecap:round}
.pc-dot{fill:var(--anatomy-lit)}
.pc-cord{stroke-width:1.1px}
.pc-key,.pc-computer{cursor:pointer}
.pc-key[data-down] .iso-edge{stroke:var(--anatomy-hi)}
[data-part="led"]{transition:opacity .4s var(--anatomy-ease)}
@keyframes pc-blink{50%{opacity:0}}
@media (prefers-reduced-motion:reduce){.pc-cursor{animation:none}}
.pc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
.iso-stage{touch-action:pan-y}
.iso-stage:focus{outline:none}
.iso-stage:focus-visible{outline:1px solid var(--anatomy-muted);outline-offset:6px}
</style>`;

const body =
  css +
  k.plateHtml({
    fig: "Fig 2",
    title: "Desk computer",
    hint: "Type on it · click it to switch",
    readout: "on · 0 chars",
    keys: [
      { mark: "lit", label: "The screen: the Anthropic mark and what you type" },
      { mark: "raised", label: "Fifty-eight keys; each one goes down as it is typed" },
      { mark: "edge", label: "A coiled cord from the keyboard to the side port" },
    ],
    caption:
      "An all-in-one desk computer in the spirit of 1984: a CRT set into a tall case, a floppy slot and speaker grille under it, vents and a rocker switch on the side. Focus it and type: each key you press goes down on the keyboard and the character lands on the screen beneath the Anthropic mark. Backspace and Return work, Escape or a click on the case switches it off and the picture collapses to a line and a dot, as a CRT does.",
    body: `<div class="iso-stage" tabindex="0" role="group" aria-label="Desk computer. Type to write on its screen; Escape switches the power.">${svg}<span class="pc-sr" role="status" aria-live="polite" data-status></span></div>`,
  });

const name = theme === "light" ? "desk-computer-light.html" : "desk-computer.html";
writeFileSync(join(HERE, name), k.pageHtml({ title: "Desk computer", theme, body, script: kitScript() + live }));
console.log("wrote", name);
```

## examples/desk-computer/desk-computer-light.html

```

```

## examples/desk-computer/desk-computer.html

```

```

## examples/dial-indicator

```

```

## examples/dial-indicator/build.mjs

```js
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import { kitScript } from "../../scripts/inline-kit.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const theme = process.argv.includes("--light") ? "light" : "dark";

const WIDTH = 600;
const HEIGHT = 380;

const PLATE = { x: -128, y: -64, w: 262, d: 128, r: 8 };
const PLATE_H = 12;
const FOOT_H = 5;

const MAG = { x: -112, y: -20, w: 44, d: 40, r: 3 };
const MAG_H = 24;
const POST = [-98, 0];
const POST_TOP = 122;
const CLAMP_Z = 100;

const DIAL = [58, 0];
const DIAL_R = 20;
const DIAL_Z = 82;
const DIAL_H = 10;
const DIAL_TOP = DIAL_Z + DIAL_H;
const PER_TURN = 20;

const RAIL_FROM = 2;
const RAIL_TO = 124;
const CARRIAGE = { x: -28, y: -24, w: 56, d: 48, r: 3 };
const CARRIAGE_Z = 3;
const CARRIAGE_TOP = 8;
const BLOCKS = [18, 14, 10];
const BLOCK_W = 15.6;
const STEP = 16;
const TIP_R = 2.6;
const CENTRE = -24 + BLOCK_W / 2;
const STOPS = BLOCKS.map((_, index) => DIAL[0] - CENTRE - STEP * index);
const REACH = [STOPS[STOPS.length - 1] - 6, STOPS[0] + 6];

const P = k.fitProjection(
  [...k.boxCorners(PLATE, -PLATE_H - FOOT_H, 0), [POST[0], POST[1], POST_TOP + 2], [DIAL[0] + DIAL_R, 0, DIAL_TOP]],
  WIDTH,
  HEIGHT,
  { pad: 30, azimuth: 42 },
);

const at = (point) => k.iso(point, P);
const S = (paths, style) => k.solidSvg(paths, style);
const L = (d, style) => k.lineSvg(d, style);
const D = (points, style) => k.dotsSvg(points, style);
const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));

function plate() {
  const out = [`<path class="iso-halo" d="${k.haloOf(PLATE, -PLATE_H - FOOT_H, PLATE_H, P)}"/>`];
  for (const [x, y] of byDepth(k.corners(PLATE, 22))) {
    out.push(S(k.cylinder(x, y, 7, -PLATE_H - FOOT_H, 1.6, P, 28), { tone: "lo" }));
    out.push(S(k.cylinder(x, y, 5.4, -PLATE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" }));
  }
  out.push(S(k.slabOf(PLATE, -PLATE_H, PLATE_H, P, 8, 1.6), { tone: "mid" }));
  out.push(L(k.planOutline(k.insetPlan(PLATE, 5), 0, P), { tone: "lo" }));
  const screws = k.corners(PLATE, 10);
  out.push(L(screws.map(([x, y]) => k.ring(x, y, 2.2, 0, P, 16)).join(""), { tone: "lo" }));
  out.push(D(screws.map(([x, y]) => at([x, y, 0])), { size: 0.5 }));
  const strip = { x: -110, y: PLATE.y + PLATE.d - 15, w: 230, d: 7, r: 1 };
  out.push(S(k.slabOf(strip, 0, 0.6, P, 3), { tone: "lo", crease: "none" }));
  const ticks = k.topTicks(strip.x + 4, strip.x + strip.w - 4, 4, 5, strip.y + 0.8, 0.6, [2, 3.6], P);
  out.push(L(ticks.minor, { tone: "lo" }), L(ticks.major, { tone: "mid" }));
  out.push(D([at([strip.x + 2, strip.y + 3.5, 0.6]), at([strip.x + strip.w - 2, strip.y + 3.5, 0.6])], { size: 0.45 }));
  return out.join("");
}

function rails() {
  const out = [];
  for (const y of [-20, 15]) {
    out.push(S(k.slabOf({ x: RAIL_FROM, y, w: RAIL_TO - RAIL_FROM, d: 5, r: 1 }, 0, 3, P, 3, 0.4), { tone: "mid" }));
    out.push(D([RAIL_FROM + 6, RAIL_TO - 6].map((x) => at([x, y + 2.5, 3])), { size: 0.4 }));
  }
  for (const x of [RAIL_FROM - 5, RAIL_TO]) {
    out.push(S(k.slabOf({ x, y: -23, w: 5, d: 46, r: 1.2 }, 0, 7, P, 3, 0.5), { tone: "mid" }));
    out.push(D([at([x + 2.5, -18, 7]), at([x + 2.5, 18, 7])], { size: 0.45 }));
  }
  return out.join("");
}

function fixture() {
  const out = [];
  out.push(S(k.slabOf(MAG, 0, 11, P, 6, 0.8), { tone: "mid" }));
  out.push(S(k.slabOf(k.insetPlan(MAG, 0.8), 11, MAG_H - 11, P, 6, 0.8), { tone: "mid" }));
  out.push(L(k.planOutline(k.insetPlan(MAG, 3), MAG_H, P), { tone: "faint" }));
  out.push(D([at([MAG.x + 5, MAG.y + 5, MAG_H]), at([MAG.x + 5, MAG.y + MAG.d - 5, MAG_H]), at([MAG.x + MAG.w - 5, MAG.y + 5, MAG_H])], { size: 0.45 }));
  const knob = [MAG.x + 32, MAG.y + 28];
  out.push(S(k.cylinder(knob[0], knob[1], 6, MAG_H, 4, P, 32), { tone: "mid" }));
  out.push(L(k.knurl(knob[0], knob[1], 6, MAG_H, MAG_H + 4, 36, P), { tone: "lo" }));
  out.push(L(k.lineOnTop([knob[0], knob[1]], [knob[0] + 6, knob[1]], MAG_H + 4, P), { tone: "hi" }));
  out.push(D([at([knob[0], knob[1], MAG_H + 4])], { size: 0.5, tone: "hi" }));
  out.push(S(k.cylinder(POST[0], POST[1], 5, MAG_H, 4, P, 28), { tone: "mid" }));
  const clamp = { x: POST[0] - 7, y: POST[1] - 7, w: 14, d: 14, r: 2.5 };
  out.push(S(k.cylinder(POST[0], POST[1], 3.2, MAG_H + 4, CLAMP_Z - 4 - MAG_H - 4, P, 28), { tone: "mid", crease: "none" }));
  out.push(L([60, 80].map((z) => k.sideArc(POST[0], POST[1], 3.2, z, P, 16)).join(""), { tone: "faint" }));
  out.push(S(k.slabOf(clamp, CLAMP_Z - 4, 12, P, 4, 0.6), { tone: "mid" }));
  out.push(S(k.cylinder(POST[0], POST[1], 3.2, CLAMP_Z + 8, POST_TOP - CLAMP_Z - 8, P, 28), { tone: "mid", crease: "none" }));
  out.push(S(k.cylinder(POST[0], POST[1], 3.8, POST_TOP, 2, P, 28), { tone: "mid" }));
  const arm = { x: POST[0] + 7, y: -3, w: DIAL[0] - DIAL_R - POST[0] - 7 + 2, d: 6, r: 2 };
  out.push(S(k.slabOf(arm, CLAMP_Z, 4, P, 4, 0.5), { tone: "mid" }));
  out.push(D([at([arm.x + 4, 0, CLAMP_Z + 4]), at([arm.x + arm.w - 5, 0, CLAMP_Z + 4])], { size: 0.45 }));
  const thumb = [clamp.x + 7, clamp.y + clamp.d + 3];
  out.push(S(k.slabOf({ x: thumb[0] - 1.4, y: clamp.y + clamp.d, w: 2.8, d: 3, r: 0.8 }, CLAMP_Z + 1, 4, P, 2), { tone: "mid", crease: "none" }));
  out.push(S(k.cylinder(thumb[0], thumb[1] + 1.4, 2.6, CLAMP_Z, 6, P, 20), { tone: "mid" }));
  out.push(L(k.knurl(thumb[0], thumb[1] + 1.4, 2.6, CLAMP_Z, CLAMP_Z + 6, 18, P), { tone: "lo" }));
  return out.join("");
}

function carriage() {
  const out = [`<path class="iso-halo" d="${k.haloOf(k.insetPlan(CARRIAGE, 3), CARRIAGE_Z, 0.1, P)}" style="filter:blur(4px)"/>`];
  out.push(S(k.slabOf(CARRIAGE, CARRIAGE_Z, CARRIAGE_TOP - CARRIAGE_Z, P, 5, 0.6), { tone: "mid" }));
  out.push(D([-22, -8, 8, 22].map((x) => at([x, CARRIAGE.y + CARRIAGE.d - 2.6, CARRIAGE_TOP])), { size: 0.4, tone: "lo" }));
  out.push(L(k.lineOnTop([CARRIAGE.x, -15.5], [CARRIAGE.x + CARRIAGE.w, -15.5], CARRIAGE_TOP, P), { tone: "faint" }));
  out.push(L(k.lineOnTop([CARRIAGE.x, 15.5], [CARRIAGE.x + CARRIAGE.w, 15.5], CARRIAGE_TOP, P), { tone: "faint" }));
  BLOCKS.forEach((height, index) => {
    const plan = { x: -24 + STEP * index, y: -12, w: BLOCK_W, d: 24, r: 1 };
    out.push(S(k.slabOf(plan, CARRIAGE_TOP, height, P, 3, 0.5), { tone: "hi", crease: "lo" }));
    out.push(L(k.planOutline(k.insetPlan(plan, 2), CARRIAGE_TOP + height, P, 3), { tone: "faint" }));
  });
  const handle = [CARRIAGE.x + CARRIAGE.w - 8, CARRIAGE.y + CARRIAGE.d + 3];
  out.push(S(k.slabOf({ x: handle[0] - 2, y: CARRIAGE.y + CARRIAGE.d - 1, w: 4, d: 4, r: 1 }, CARRIAGE_Z + 1, 3, P, 2), { tone: "mid", crease: "none" }));
  out.push(S(k.cylinder(handle[0], handle[1] + 1, 3, CARRIAGE_Z, 5, P, 20), { tone: "mid" }));
  out.push(L(k.knurl(handle[0], handle[1] + 1, 3, CARRIAGE_Z, CARRIAGE_Z + 5, 18, P), { tone: "lo" }));
  return out.join("");
}

function plunger() {
  const out = [];
  out.push(S(k.cylinder(DIAL[0], DIAL[1], TIP_R, 0, 2.4, P, 20), { tone: "hi", lit: true }));
  out.push(S(k.cylinder(DIAL[0], DIAL[1], 1.4, 2.4, 50, P, 16), { tone: "hi", crease: "none", lit: true }));
  return out.join("");
}

function dial() {
  const out = [];
  const [x, y] = DIAL;
  out.push(S(k.cylinder(x, y, 2.9, 54, DIAL_Z - 54, P, 20), { tone: "mid" }));
  out.push(L(k.sideArc(x, y, 2.9, 60, P, 12), { tone: "lo" }));
  out.push(S(k.slabOf({ x: x - DIAL_R - 6, y: -4, w: 8, d: 8, r: 1.5 }, DIAL_Z + 2, CLAMP_Z - DIAL_Z - 2, P, 3, 0.4), { tone: "mid" }));
  out.push(S(k.cylinder(x, y, DIAL_R, DIAL_Z, DIAL_H, P, 64, 0.8), { tone: "hi", crease: "lo" }));
  out.push(L(k.knurl(x, y, DIAL_R, DIAL_TOP - 3.4, DIAL_TOP, 84, P, 0.3), { tone: "lo" }));
  out.push(L(k.sideArc(x, y, DIAL_R, DIAL_TOP - 3.4, P, 40), { tone: "faint" }));
  out.push(L(k.ring(x, y, DIAL_R - 2.2, DIAL_TOP, P, 64), { tone: "lo" }));
  const ticks = k.radialTicks(x, y, DIAL_R - 3, 50, 5, DIAL_TOP, [1.3, 2.8], P);
  out.push(L(ticks.minor, { tone: "lo" }), L(ticks.major, { tone: "mid" }));
  out.push(S(k.slabOf({ x: x + DIAL_R - 1, y: -2.4, w: 5, d: 4.8, r: 1.2 }, DIAL_Z + 2, 5, P, 3), { tone: "mid" }));
  out.push(S(k.slabOf({ x: x + DIAL_R + 3.6, y: -3.2, w: 2.6, d: 6.4, r: 1.2 }, DIAL_Z + 1.2, 6.6, P, 3), { tone: "mid" }));
  out.push(L(k.ring(x, y, 1.6, DIAL_TOP, P, 16), { tone: "mid" }));
  out.push(k.faceTextSvg(k.topMatrix([x, y + 9], DIAL_TOP, P, "x"), "0.01", { size: 2.4, tone: "lo", anchor: "middle" }));
  return out.join("");
}

const needleAt = (turn) => {
  const angle = turn * Math.PI * 2;
  const [x, y] = DIAL;
  return k.lineOnTop([x - Math.sin(angle) * 3.4, y + Math.cos(angle) * 3.4], [x + Math.sin(angle) * (DIAL_R - 4), y - Math.cos(angle) * (DIAL_R - 4)], DIAL_TOP + 0.2, P);
};

const startX = STOPS[0];
const svg = k.figureSvg({
  width: WIDTH,
  height: HEIGHT,
  label:
    "A dial indicator on a surface plate. A magnetic base holds a post and an arm; the arm carries a dial whose plunger rests on three gauge blocks, 18, 14 and 10 tall, on a carriage that slides along two rails. Wherever the carriage stops, the plunger rises to the block under it and the needle turns by the same amount.",
  body: [
    plate(),
    rails(),
    fixture(),
    `<g data-part="carriage" transform="${k.translateAlong(P, [startX, 0, 0])}">${carriage()}</g>`,
    `<g data-part="plunger" transform="${k.translateAlong(P, [0, 0, CARRIAGE_TOP + BLOCKS[0]])}">${plunger()}</g>`,
    dial(),
    `<path data-part="needle" class="iso-line" data-tone="lit" data-free="start" d="${needleAt(BLOCKS[0] / PER_TURN)}" style="stroke-width:1.1"/>`,
  ],
});

const live = `
const P = ${JSON.stringify(P)};
const BLOCKS = ${JSON.stringify(BLOCKS)};
const DIAL = ${JSON.stringify(DIAL)};
const DIAL_R = ${DIAL_R}, DIAL_TOP = ${DIAL_TOP}, PER_TURN = ${PER_TURN}, STEP = ${STEP}, BLOCK_W = ${BLOCK_W}, TIP_R = ${TIP_R}, BASE = ${CARRIAGE_TOP};
const REACH = ${JSON.stringify(REACH)};
const stage = document.querySelector(".iso-stage");
const svg = stage.querySelector("svg");
const carriage = svg.querySelector('[data-part="carriage"]');
const plunger = svg.querySelector('[data-part="plunger"]');
const needle = svg.querySelector('[data-part="needle"]');
const readout = document.querySelector("[data-readout]");
const still = matchMedia("(prefers-reduced-motion: reduce)");
const stops = ${JSON.stringify(STOPS)};
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
const heightUnder = (cx) => {
  let top = BASE;
  BLOCKS.forEach((height, index) => {
    const from = cx - 24 + STEP * index, to = from + BLOCK_W;
    if (to > DIAL[0] - TIP_R && from < DIAL[0] + TIP_R) top = Math.max(top, BASE + height);
  });
  return top;
};
const blockUnder = (cx) => BLOCKS.findIndex((_, index) => { const from = cx - 24 + STEP * index; return DIAL[0] >= from && DIAL[0] <= from + BLOCK_W; });
const needleAt = ${needleAt.toString().replace("k.lineOnTop", "lineOnTop")};
let x = ${startX}, target = x, tip = { at: BASE + BLOCKS[0], rate: 0 };
let frame = 0, last = 0, clock = 0, touring = true, visible = false, idleTimer = 0;
const tourAt = (t) => stops[Math.floor(t / 2.2) % stops.length];
function draw() {
  carriage.setAttribute("transform", translateAlong(P, [x, 0, 0]));
  plunger.setAttribute("transform", translateAlong(P, [0, 0, tip.at]));
  needle.setAttribute("d", needleAt((tip.at - BASE) / PER_TURN));
  const index = blockUnder(x);
  const text = (index < 0 ? "between blocks" : "block " + (index + 1)) + " · reads " + (tip.at - BASE).toFixed(1) + " px";
  if (readout.textContent !== text) readout.textContent = text;
  stage.setAttribute("aria-valuenow", String(Math.round(tip.at - BASE)));
  stage.setAttribute("aria-valuetext", text);
}
function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  const calm = still.matches;
  if (touring) { clock += dt; target = tourAt(clock); }
  x = calm ? target : x + (target - x) * (1 - Math.exp(-dt / 0.28));
  if (Math.abs(target - x) < 0.01) x = target;
  const want = heightUnder(x);
  const stiff = 420, drag = calm ? 2 * Math.sqrt(420) : 26;
  for (let i = 0; i < 4; i++) { tip.rate += ((want - tip.at) * stiff - tip.rate * drag) * dt / 4; tip.at += tip.rate * dt / 4; }
  if (Math.abs(want - tip.at) < 0.005 && Math.abs(tip.rate) < 0.005) { tip.at = want; tip.rate = 0; }
  draw();
  const settled = !touring && x === target && tip.rate === 0 && tip.at === want;
  frame = visible && !settled ? requestAnimationFrame(tick) : 0;
}
const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } };
const steer = (next) => { touring = false; target = clamp(next, REACH[0], REACH[1]); clearTimeout(idleTimer); idleTimer = setTimeout(() => { touring = !still.matches; clock = 0; run(); }, 3600); run(); };
new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage);
if (still.matches) touring = false;
stage.addEventListener("pointermove", (event) => {
  const box = svg.getBoundingClientRect();
  const share = clamp(((event.clientX - box.left) / box.width - 0.25) / 0.5, 0, 1);
  steer(REACH[0] + share * (REACH[1] - REACH[0]));
});
stage.addEventListener("pointerleave", () => steer(target));
stage.addEventListener("keydown", (event) => {
  const here = stops.reduce((best, stop, index) => (Math.abs(stop - target) < Math.abs(stops[best] - target) ? index : best), 0);
  let to = null;
  if (event.key === "ArrowRight" || event.key === "ArrowUp") to = Math.max(0, here - 1);
  if (event.key === "ArrowLeft" || event.key === "ArrowDown") to = Math.min(stops.length - 1, here + 1);
  if (event.key === "Home") to = 0;
  if (event.key === "End") to = stops.length - 1;
  if (to === null) return;
  event.preventDefault();
  steer(stops[to]);
});
draw();
`;

const body = k.plateHtml({
  fig: "Fig 1",
  title: "Dial indicator",
  hint: "Hover to slide the carriage",
  readout: `block 1 · reads ${BLOCKS[0].toFixed(1)} px`,
  keys: [
    { mark: "lit", label: "Plunger and needle: what moves when the height changes" },
    { mark: "raised", label: "Three gauge blocks, 18, 14 and 10 tall" },
  ],
  caption:
    "A dial indicator on a surface plate. Slide the carriage and the plunger rides up or down onto the block beneath it; one turn of the needle is 20 px of travel, so every block reads its own height.",
  body: `<div class="iso-stage" tabindex="0" role="slider" aria-label="Dial indicator: arrow keys move the carriage from block to block" aria-valuemin="0" aria-valuemax="18" aria-valuenow="18">${svg}</div>`,
});

writeFileSync(join(HERE, theme === "light" ? "dial-indicator-light.html" : "dial-indicator.html"), k.pageHtml({ title: "Dial indicator", theme, body, script: kitScript() + live }));
console.log("wrote", theme === "light" ? "dial-indicator-light.html" : "dial-indicator.html");
```

## examples/dial-indicator/dial-indicator-light.html

```

```

## examples/dial-indicator/dial-indicator.html

```

```

## examples/raptor-engine

```

```

## examples/raptor-engine/build.mjs

```js
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import * as G from "../../kit/lathe.mjs";
import { tubePieces, tubeSvg, crossingsOf } from "../../kit/tube.mjs";
import * as A from "../../kit/audit.mjs";
import { glCamera } from "../../kit/gl.mjs";
import { glScript } from "../../scripts/inline-kit.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const THEME = process.argv.includes("--light") ? "light" : "dark";
const DEG = Math.PI / 180;

const W = 932;
const H = 900;
const AZIMUTH = 38;
const ELEVATION = 27;
const Z_EXIT = 172;
const TOP = 310;
const BEAM_Z = Z_EXIT + TOP + 23;
const BEAM_H = 28;
const DECK = { x: -150, y: -150, w: 300, d: 300, r: 10 };
const DECK_H = 15;
const FOOT_H = 8;
const COLUMN = 24;
const COLUMN_AT = [
  [-124, 124],
  [124, -124],
];
const CONE_TIP = 32;
const CHAMBER_BAR = 300;
const SEA_LEVEL_TF = 230;
const EXIT_M = 1.3;
const BACK_TF = (101325 * Math.PI * (EXIT_M / 2) ** 2) / 9806.65;

const P = k.fitProjection(
  [
    ...k.boxCorners(DECK, -DECK_H - FOOT_H, 0),
    [COLUMN_AT[0][0] - COLUMN, COLUMN_AT[0][1] + COLUMN, BEAM_Z + BEAM_H],
    [COLUMN_AT[1][0] + COLUMN, COLUMN_AT[1][1] - COLUMN, BEAM_Z + BEAM_H],
    [-190 / Math.SQRT2, 190 / Math.SQRT2, BEAM_Z + BEAM_H],
    [190 / Math.SQRT2, -190 / Math.SQRT2, BEAM_Z + BEAM_H],
  ],
  W,
  H,
  { pad: 26, azimuth: AZIMUTH, elevation: ELEVATION },
);
const V = G.viewOf(P);
const KS = G.scaleOf(P);
const at = (point) => k.iso(point, P);
const S = (paths, style) => k.solidSvg(paths, style);
const Ln = (d, style) => k.lineSvg(d, style);
const Dots = (points, style) => k.dotsSvg(points, style);
const FADE = [0.04, 0.42];
const Faded = (list, style) => k.fadedSvg(list, style);

const polar = (deg, r, s, centre = [0, 0]) => [centre[0] + r * Math.cos(deg * DEG), centre[1] + r * Math.sin(deg * DEG), Z_EXIT + s];
const dir = (deg) => [Math.cos(deg * DEG), Math.sin(deg * DEG), 0];
const ENGINE = G.frameOf([0, 0, Z_EXIT], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const FTP_DEG = -20;
const FTP_AT = [72 * Math.cos(FTP_DEG * DEG), 72 * Math.sin(FTP_DEG * DEG)];
const FTP = G.frameOf([FTP_AT[0], FTP_AT[1], Z_EXIT], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const ftp = (deg, r, s) => G.pointOf(FTP, s, r, deg * DEG);
const DECKF = G.frameOf([0, 0, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const out = (deg, r, z) => [r * Math.cos(deg * DEG), r * Math.sin(deg * DEG), z];
const LUGS = [
  [135, 185],
  [-45, -45],
];

const PIECES = {
  gimbal: { name: "Gimbal and thrust puck", say: "gimbal · carries 230 tf into the stand", move: [0, 0, 0] },
  otp: { name: "Oxygen turbopump", say: "oxygen turbopump · spun by oxygen-rich gas", move: [0, 0, -26] },
  orpb: { name: "Oxygen-rich preburner", say: "oxygen-rich preburner · all the oxygen", move: [0, 0, -52] },
  lox: { name: "Oxygen duct and main valve", say: "oxygen duct · pump to preburner", move: out(166, 52, -12) },
  chamber: { name: "Injector and main chamber", say: "main chamber · 300 bar · no igniter", move: [0, 0, -80] },
  nozzle: { name: "Nozzle", say: "nozzle · 1.3 m exit · methane cooled", move: [0, 0, -120] },
  ftp: { name: "Methane turbopump and fuel-rich preburner", say: "methane turbopump · spun by fuel-rich gas", move: out(FTP_DEG, 70, -46) },
  hotgas: { name: "Hot-gas manifold", say: "hot-gas manifold · fuel-rich gas to the injector", move: out(FTP_DEG, 36, -62) },
  downcomer: { name: "Downcomer and main fuel valve", say: "downcomer · methane to the cooling jacket", move: out(40, 66, -84) },
  plumbing: { name: "Plumbing", say: "plumbing", move: [0, 0, 0] },
  controller: { name: "Engine controller and harness", say: "engine controller", move: out(70, 70, -28) },
  stand: { name: "Stand", say: "", move: [0, 0, 0] },
};

const STAGGER = ["nozzle", "downcomer", "controller", "plumbing", "lox", "ftp", "hotgas", "chamber", "orpb", "otp", "gimbal", "stand"];
const SPRING = { stagger: 0.035, stiffness: 64, damping: 14.2, substeps: 4 };
const R = A.recorder(P, { order: STAGGER, spring: SPRING });
const { items, nameFor } = R;
const record = R.solid;
const rideOf = (piece, move) => (move ? Object.keys(PIECES).find((id) => PIECES[id].move === move) ?? piece : piece);
function put(piece, ref, svg, { bias = 0, move, ride, shapes = [], name, route, chunk } = {}) {
  R.put({ piece, ride: ride ?? rideOf(piece, move), at: ref, bias, svg, move: move ?? PIECES[piece].move, shapes, name: name ?? nameFor(`${piece}.item`), route, chunk });
}
function tubeItems(piece, route, r, { name, tone = "hi", bias = 0, move, maxLength, spacing, closed = false, owner, touch, bundle, limp = false, breaks = [], gaps = [], caps = true, ends = [true, true], rims, free = [false, false] } = {}) {
  const id = nameFor(name ?? `${piece}.tube`);
  R.route(id, route, r, { owner: owner ?? id, touch, bundle, limp, gaps, closed, ends, rims, free, piece, move: move ?? PIECES[piece].move, ride: rideOf(piece, move) });
  tubePieces(route, r, P, { maxLength, spacing, closed, breaks, gaps, caps }).forEach((part, index) =>
    put(piece, part.mid, tubeSvg(part, { tone }), { bias, move, name: `${id}:${index}`, route: id, chunk: index, shapes: [A.tube(part.points, part.radii)] }),
  );
  return id;
}

const ex = (F, s) => [F.o[0], F.o[1], F.o[2] + s];

function turned(piece, F, profile, style = {}, { extra = "", inner = "", bias = 0, move, rims, bevel = 0, steps, name, owner, smooth = profile.some((point) => point[2]) } = {}) {
  const paths = G.solidOf(profile, F, P, { bevel, smooth: smooth === true ? { rows: Math.max(...profile.map(([, r]) => r)) < 20 ? 3 : 5 } : smooth, ...(rims ? { rims } : {}), ...(steps ? { steps } : {}) });
  const mid = (profile[0][0] + profile[profile.length - 1][0]) / 2;
  const id = nameFor(name ?? `${piece}.turned${Math.round(profile[0][0])}`);
  const shape = record(A.solid(F, profile, { name: id, owner, curve: smooth && !smooth.slope ? profile : undefined }));
  put(piece, G.add3(F.o, G.mul3(F.a, mid)), S(paths, { tone: "hi", ...style, inner: inner ? Ln(inner, { tone: "faint" }) : "" }) + extra, { bias, move, name: id, shapes: [shape] });
  return shape;
}

function band(piece, F, s0, s1, r, style = {}, options = {}) {
  return turned(piece, F, [
    [s0, r],
    [s1, r],
  ], style, options);
}

function flangeAt(piece, centre, axis, r, thick, { bolts = 8, tone = "hi", move, bias = 0, boltR, name, owner } = {}) {
  const F = G.frameAlong(centre, axis);
  const id = nameFor(name ?? `${piece}.flange`);
  const paths = G.disc(-thick / 2, thick / 2, r, F, P);
  const faces = [];
  const towards = G.dot3(F.a, V) > 0 ? thick / 2 : -thick / 2;
  if (Math.abs(G.dot3(F.a, V)) > 0.12 && bolts) faces.push(Dots(G.dotsOf(F, towards, boltR ?? r * 0.74, bolts, P, { all: true }), { size: 0.42, tone: "mid" }));
  put(piece, centre, S(paths, { tone }) + faces.join(""), { bias, move, name: id, shapes: [record(A.disc(F, -thick / 2, thick / 2, r, { name: id, owner }))] });
}

function pipe(piece, control, r, { bend = 2.6, tone = "hi", flanges = [true, true], flangeR = 1.55, coupling = false, bands = [], bellows = null, valve = null, move, bias = 0, maxLength = 26, spacing, name, touch, breaks = [], gaps: extra = [], free } = {}) {
  const flangeRs = [flangeR].flat().length > 1 ? [flangeR].flat() : [flangeR, flangeR].flat();
  if (coupling) flanges = [flanges[0], false];
  const line = G.fillet(control, Math.max(r * bend, 3), r > 3 ? 24 : 12, { P, tube: r });
  const total = G.pathLength(line);
  const collars = [];
  [[0, -1], [total, 1]].forEach(([along, sign], index) => {
    if (!flanges[index]) return;
    const thick = Math.max(0.9, r * 0.55);
    const tangent = G.tangentAlong(line, along);
    collars.push({ kind: "flange", centre: G.add3(G.pointAlong(line, along), G.mul3(tangent, (-sign * thick) / 2)), tangent, radius: r * flangeRs[index], thick, gap: sign < 0 ? [0, thick] : [total - thick, total] });
  });
  if (coupling) {
    const thick = Math.max(1.6, r * 1.4);
    collars.push({ kind: "coupling", centre: G.pointAlong(line, total - thick / 2), tangent: G.tangentAlong(line, total), radius: r * 1.5, thick, gap: [total - thick, total] });
  }
  const ring = (along, radius, thick, kind) => collars.push({ kind, centre: G.pointAlong(line, along), tangent: G.tangentAlong(line, along), radius, thick, gap: [along - thick / 2, along + thick / 2] });
  for (const share of bands) ring(total * share, r * 1.18, Math.max(0.8, r * 0.7), "band");
  if (bellows) {
    const [from, to, count] = bellows;
    for (let index = 0; index < count; index++) ring(total * (from + ((to - from) * index) / (count - 1)), r * 1.26, Math.max(0.6, r * 0.3), "bellows");
  }
  const gaps = [...collars.map(({ gap }) => gap), ...extra];
  if (valve) {
    const along = valve[0] > 1 ? valve[0] : total * valve[0];
    gaps.push([along - valve[1] * 2.4, along + valve[1] * 2.4]);
  }
  const owner = tubeItems(piece, line, r, { name: name ?? `${piece}.pipe`, tone, bias, move, maxLength, spacing: spacing ?? Math.max(0.8, Math.min(2.4, r * 0.18)), touch, gaps, breaks, free, rims: flanges.map((flanged, index) => (flanged ? r * flangeRs[index] : index && coupling ? r * 1.5 : r)) });
  for (const { kind, centre, tangent, radius, thick } of collars)
    flangeAt(piece, centre, tangent, radius, thick, { bolts: kind === "flange" && r > 3 ? 8 : 0, tone: kind === "flange" || kind === "coupling" ? tone : "mid", move, bias, name: `${owner}.${kind}`, owner });
  line.owner = owner;
  return line;
}

function boxAt(piece, plan, z, h, style = {}, { extra = "", bias = 0, move, bevel = 0.5, steps = 3, name, owner } = {}) {
  const centre = [plan.x + plan.w / 2, plan.y + plan.d / 2, z + h / 2];
  const id = nameFor(name ?? `${piece}.box`);
  put(piece, centre, S(k.slabOf(plan, z, h, P, steps, bevel), { tone: "hi", ...style }) + extra, { bias, move, name: id, shapes: [record(A.box([plan.x, plan.y, z], [plan.x + plan.w, plan.y + plan.d, z + h], { name: id, owner }))] });
}

function valveOn(piece, line, share, r, { move, tone = "hi", name, side = [0, 0, 1] } = {}) {
  const total = G.pathLength(line);
  const along = share > 1 ? share : total * share;
  const centre = G.pointAlong(line, along);
  const tangent = G.tangentAlong(line, along);
  const F = G.frameAlong(centre, tangent);
  const body = r * 1.85;
  const id = nameFor(name ?? `${line.owner}.valve`);
  const owner = line.owner;
  const crown = body * 1.08;
  const bulge = (x) => r * 1.45 + (crown - r * 1.45) * Math.sqrt(Math.max(0, 1 - (x / (r * 1.6)) ** 2));
  const bulgeSlope = (x) => {
    if (Math.abs(x) >= r * 1.6 - 1e-9) return 0;
    const t = Math.max(-0.995, Math.min(0.995, x / (r * 1.6)));
    return (-(crown - r * 1.45) * t) / (r * 1.6 * Math.sqrt(1 - t * t));
  };
  const meridian = [
    [-r * 2.4, 0],
    [-r * 2.4, r * 1.45],
    ...Array.from({ length: 17 }, (_, index) => {
      const x = r * 1.6 * Math.sin(((index - 8) / 8) * (Math.PI / 2));
      return [x, bulge(x), index && index < 16 ? 1 : 0];
    }),
    [r * 2.4, r * 1.45],
    [r * 2.4, 0],
  ];
  put(
    piece,
    centre,
    S(G.lathe(meridian, F, P, { steps: 36, smooth: { slope: bulgeSlope, rows: 3 } }), { tone }) + Dots([...G.dotsOf(F, -r * 2.4, r * 1.2, 8, P, { all: true }), ...G.dotsOf(F, r * 2.4, r * 1.2, 8, P, { all: true })].filter((_, index) => index % 2 === 0), { size: 0.4, tone: "mid" }),
    { move, name: id, shapes: [record(A.body(F, meridian, { name: id, owner }))] },
  );
  const up = G.unit3(side);
  const NF = G.frameAlong(centre, up);
  const s0 = body * 1.08 - 0.25;
  const s1 = body * 1.08 + body * 0.45;
  put(piece, G.add3(centre, G.mul3(up, (s0 + s1) / 2)), S(G.disc(s0, s1, r * 0.5, NF, P), { tone }), { move, bias: 0.2, name: `${id}.neck`, shapes: [record(A.disc(NF, s0, s1, r * 0.5, { name: `${id}.neck`, owner }))] });
  if (up[2] > 0.99) {
    const top = G.add3(centre, [0, 0, s1]);
    const plan = { x: top[0] - r * 1.5, y: top[1] - r * 1.5, w: r * 3, d: r * 3, r: 0.8 };
    const lid = top[2] + r * 2.2;
    boxAt(piece, plan, top[2], r * 2.2, { tone }, {
      move,
      bias: 0.3,
      name: `${id}.actuator`,
      owner,
      extra: Ln(k.planOutline(k.insetPlan(plan, 0.8), lid, P, 2), { tone: "faint" }) + Dots([at([plan.x + 1.2, plan.y + 1.2, lid]), at([plan.x + plan.w - 1.2, plan.y + plan.d - 1.2, lid])], { size: 0.4 }),
    });
    const port = [top[0], top[1], lid];
    return { port, approach: G.add3(port, [0, 0, 5]), lid };
  }
  const can = [[s1, 0], [s1, r * 1.5], [s1 + r * 2.2, r * 1.5], [s1 + r * 2.2, 0]];
  const end = s1 + r * 2.2;
  put(piece, G.add3(centre, G.mul3(up, (s1 + end) / 2)), S(G.lathe(can, NF, P, { steps: 32, bevel: 0.6 }), { tone }) + Ln(G.circleOf(NF, end, r * 1.1, P, 24), { tone: "faint" }) + Dots(G.dotsOf(NF, end, r * 1.25, 4, P, { all: true, phase: Math.PI / 4 }), { size: 0.4 }), { move, bias: 0.3, name: `${id}.actuator`, shapes: [record(A.body(NF, can, { name: `${id}.actuator`, owner }))] });
  const port = G.add3(centre, G.mul3(up, end));
  return { port, approach: G.add3(port, G.mul3(up, 5)) };
}

function ballAt(piece, centre, r, { move, tone = "mid", bias = 0, name, owner } = {}) {
  const F = G.frameAlong(centre, [0, 0, 1]);
  const id = nameFor(name ?? `${piece}.ball`);
  put(piece, centre, S(G.sphereOf(centre, r, P, { steps: G.stepsFor(r, 32) }), { tone }), { move, bias, name: id, shapes: [record(A.ball(centre, r, { name: id, owner }))] });
}

function elbow(piece, face, normal, faceR, toward, ball, rim, { name, owner } = {}) {
  const centre = G.add3(face, G.mul3(normal, Math.sqrt(ball ** 2 - faceR ** 2)));
  ballAt(piece, centre, ball, { tone: "hi", name, owner });
  const axis = G.unit3(G.sub3(toward, centre));
  return G.add3(centre, G.mul3(axis, Math.sqrt(ball ** 2 - rim ** 2)));
}

const sideOn = (d) => G.unit3(G.cross3(G.unit3(G.sub3(V, G.mul3(d, G.dot3(V, d)))), d));

function boss(piece, centre, outward, r, length, { move, tone = "hi", bias = 0.1, name, host, ring = true, owner } = {}) {
  if (host) return padOn(piece, host, centre, outward, r, length, { move, tone, bias, name: name ?? `${piece}.boss`, ring, owner });
  const F = G.frameAlong(centre, outward);
  const id = nameFor(name ?? `${piece}.boss`);
  put(piece, G.add3(centre, G.mul3(F.a, length / 2)), S(G.disc(0, length, r, F, P), { tone }) + (ring && G.dot3(F.a, V) > 0.15 ? Ln(G.circleOf(F, length, r * 0.55, P, 16), { tone: "faint" }) : ""), { bias, move, name: id, shapes: [record(A.disc(F, 0, length, r, { name: id, owner }))] });
  return G.add3(centre, G.mul3(F.a, length));
}

function padOn(piece, host, point, outward, a, h, { move, name, tone = "hi", bias = 0.1, owner, ring = false } = {}) {
  const hosts = [host].flat();
  const inside = (p) => hosts.some((shape) => A.sdf(shape, p) < 0);
  const axis = G.unit3(outward);
  const F = G.frameAlong(point, axis);
  const start = (angle) => {
    let lo = -2.5 * a;
    let hi = h;
    if (!inside(G.pointOf(F, lo, a, angle))) return 0;
    for (let step = 0; step < 40; step++) {
      const mid = (lo + hi) / 2;
      if (inside(G.pointOf(F, mid, a, angle))) lo = mid;
      else hi = mid;
    }
    return hi;
  };
  const low = Math.min(...Array.from({ length: 48 }, (_, index) => start((index / 48) * Math.PI * 2)));
  const id = nameFor(name ?? `${piece}.pad`);
  const face = ring && G.dot3(axis, V) > 0.15 ? Ln(G.circleOf(F, h, a * 0.55, P, 16), { tone: "faint" }) : "";
  put(piece, G.add3(point, G.mul3(axis, h / 2)), S(G.saddleOf(F, a, start, h, P), { tone }) + face, { bias, move, name: id, shapes: [record(A.disc(F, low - 0.05, h, a, { name: id, owner, seated: true, cut: hosts }))] });
  return G.add3(point, G.mul3(axis, h));
}

const CABLES = [];
function cable(piece, points, { move, bias = 0.2, r = 0.62, bend = 6, name, bundle, touch, clamps = [], shells = [false, false], free } = {}) {
  const route = G.fillet(points, bend, 12, { P, tube: r });
  CABLES.push({ piece, route });
  const total = G.pathLength(route);
  const SHELL = 2.2;
  const gaps = [...clamps.flatMap(({ centre, axis, radius }) => crossingsOf(route, centre, axis, radius).map((at) => [at - CLAMP_HALF - 0.05, at + CLAMP_HALF + 0.05])), ...(shells[0] ? [[0, SHELL]] : []), ...(shells[1] ? [[total - SHELL, total]] : [])];
  const id = tubeItems(piece, route, r, { name: name ?? `${piece}.cable`, tone: "mid", bias, move, maxLength: 14, spacing: 1, bundle, touch, gaps, free, limp: true, rims: shells.map((on) => (on ? r * 2 : r)) });
  [0, 1].forEach((index) => {
    const end = index ? route[route.length - 1] : route[0];
    if (!shells[index]) {
      put(piece, end, Dots([at(end)], { size: 0.5, tone: "hi" }), { bias: bias + 0.15, move, name: `${id}.end`, shapes: [A.ball(end, 0.5 / KS)] });
      return;
    }
    const tangent = G.tangentAlong(route, index ? total : 0);
    const F = G.frameAlong(end, tangent);
    const [s0, s1] = index ? [-SHELL, 0] : [0, SHELL];
    const outer = index ? s0 : s1;
    put(piece, G.add3(end, G.mul3(tangent, (s0 + s1) / 2)), S(G.disc(s0, s1, r * 2, F, P, { bevel: 0.25 }), { tone: "mid" }) + (G.dot3(G.mul3(F.a, index ? -1 : 1), V) > 0.2 ? Ln(G.circleOf(F, outer, r * 1.1, P, 12), { tone: "faint" }) : ""), { bias: bias + 0.1, move, name: `${id}.shell`, shapes: [record(A.disc(F, s0, s1, r * 2, { name: `${id}.shell`, owner: id }))] });
  });
  return route;
}

const CLAMP_HALF = 0.9;
function clampAt(piece, centre, axis, r, { move, bias = 0.3, name = `${piece}.clamp`, bundle } = {}) {
  const F = G.frameAlong(centre, axis);
  const id = nameFor(name);
  put(piece, centre, S(G.disc(-CLAMP_HALF, CLAMP_HALF, r, F, P), { tone: "mid" }), { bias, move, name: id, shapes: [record(A.disc(F, -CLAMP_HALF, CLAMP_HALF, r, { name: id, anchor: false, bundle, fitting: true }))] });
}


const back = [];
back.push(`<path class="iso-halo" d="${k.haloOf(DECK, -DECK_H - FOOT_H, DECK_H, P)}"/>`);
const FEET = [
  [-132, -132],
  [132, -132],
  [-132, 132],
  [132, 132],
  [0, -132],
  [0, 132],
  [-132, 0],
  [132, 0],
].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));
for (const [x, y] of FEET) {
  back.push(S(k.cylinder(x, y, 10, -DECK_H - FOOT_H, 2, P, 28), { tone: "lo" }));
  back.push(S(k.cylinder(x, y, 7.5, -DECK_H - FOOT_H + 2, FOOT_H - 2, P, 28), { tone: "mid" }));
}
back.push(S(k.slabOf(DECK, -DECK_H, DECK_H, P, 8, 1.6), { tone: "mid" }));
const FRAME = { owner: "stand.frame" };
record(A.box([DECK.x, DECK.y, -DECK_H], [DECK.x + DECK.w, DECK.y + DECK.d, 0], { name: "stand.deck", ...FRAME }));
back.push(Ln(k.planOutline(k.insetPlan(DECK, 7), 0, P), { tone: "lo" }));
const DECK_SCREWS = k.corners(DECK, 15);
back.push(Ln(DECK_SCREWS.map(([x, y]) => k.ring(x, y, 3, 0, P, 18)).join(""), { tone: "lo" }));
back.push(Dots(DECK_SCREWS.map(([x, y]) => at([x, y, 0])), { size: 0.55 }));
back.push(Ln(k.sideTicks(DECK.x + 20, DECK.x + DECK.w - 20, 10, 5, DECK.x + DECK.w, 0, [2.4, 4.6], P, "y").minor, { tone: "lo" }));
back.push(Ln(k.sideTicks(DECK.x + 20, DECK.x + DECK.w - 20, 10, 5, DECK.x + DECK.w, 0, [2.4, 4.6], P, "y").major, { tone: "mid" }));

const GRATE = { x: -98, y: -98, w: 196, d: 196, r: 6 };
back.push(Ln(k.planOutline(GRATE, 0, P), { tone: "lo" }));
for (let x = GRATE.x + 14; x < GRATE.x + GRATE.w - 4; x += 14) back.push(Ln(k.lineOnTop([x, GRATE.y], [x, GRATE.y + GRATE.d], 0, P), { tone: "faint" }));

const STRIP = { x: -128, y: DECK.y + DECK.d - 15, w: 196, d: 8, r: 1 };
back.push(S(k.slabOf(STRIP, 0, 0.8, P, 3), { tone: "lo", crease: "none" }));
const TICKS = k.topTicks(STRIP.x + 4, STRIP.x + STRIP.w - 4, 4, 5, STRIP.y + 1, 0.8, [2.2, 4.4], P);
back.push(Ln(TICKS.minor, { tone: "lo" }), Ln(TICKS.major, { tone: "mid" }));
back.push(Dots([at([STRIP.x + 2.2, STRIP.y + 4, 0.8]), at([STRIP.x + STRIP.w - 2.2, STRIP.y + 4, 0.8])], { size: 0.45 }));

const LABEL = { x: DECK.x + DECK.w - 21, y: -94, w: 12, d: 60, r: 1.5 };
back.push(S(k.slabOf(LABEL, 0, 1, P, 3), { tone: "mid" }));
back.push(Ln(k.planOutline(k.insetPlan(LABEL, 1.8), 1, P), { tone: "faint" }));
back.push(k.faceTextSvg(k.topMatrix([LABEL.x + 7.4, LABEL.y + LABEL.d - 5], 1, P, "y"), "RAPTOR 2", { size: 4.2, tone: "lo" }));
back.push(Ln([0, 1].map((row) => k.onTop([[LABEL.x + 3 + row * 2.2, LABEL.y + 5], [LABEL.x + 3 + row * 2.2, LABEL.y + (row ? 16 : 21)]], 1, P)).join(""), { tone: "lo", free: true }));
back.push(Dots([at([LABEL.x + LABEL.w / 2, LABEL.y + 2.6, 1]), at([LABEL.x + LABEL.w / 2, LABEL.y + LABEL.d - 2.6, 1])], { size: 0.45 }));

const deckTop = k.slabOf(k.insetPlan(DECK, 1), 0, 0.01, P, 8).fill;
const deckCentre = at([0, 0, 0]);
back.push(`<defs><radialGradient id="rp-deck" gradientUnits="userSpaceOnUse" cx="${deckCentre[0].toFixed(1)}" cy="${deckCentre[1].toFixed(1)}" r="${(150 * KS).toFixed(1)}" gradientTransform="translate(${deckCentre[0].toFixed(1)} ${deckCentre[1].toFixed(1)}) scale(1 ${k.cameraOf(P).sinE.toFixed(3)}) translate(${(-deckCentre[0]).toFixed(1)} ${(-deckCentre[1]).toFixed(1)})"><stop offset="0" class="rp-stop-0"/><stop offset=".28" class="rp-stop-1"/><stop offset=".7" class="rp-stop-2"/><stop offset="1" class="rp-stop-3"/></radialGradient></defs><path class="rp-deckglow" fill="url(#rp-deck)" d="${deckTop}"/>`);
back.push(`<path class="iso-halo rp-shadow" d="${k.haloOf({ x: -56, y: -56, w: 112, d: 112, r: 56 }, 0, 0.1, P)}"/>`);
back.push(S(G.disc(0, 4, 56, DECKF, P, { bevel: 1.2 }), { tone: "mid" }));
record(A.disc(DECKF, 0, 4, 56, { name: "stand.cone-plate", ...FRAME }));
back.push(Dots(G.dotsOf(DECKF, 4, 52, 28, P, { all: true }), { size: 0.45, tone: "mid" }));
const CONE_Q = 0.8;
const coneShare = (s) => Math.max(1e-4, 1 - (s - 4) / (CONE_TIP - 4));
const cone = (s) => 48 * coneShare(s) ** CONE_Q;
const coneSlope = (s) => ((-48 * CONE_Q) / (CONE_TIP - 4)) * coneShare(s) ** (CONE_Q - 1);
const CONE = Array.from({ length: 33 }, (_, index) => {
  const s = 4 + (CONE_TIP - 4) * (1 - (1 - index / 32) ** 2);
  return index === 32 ? [CONE_TIP, 0] : [s, cone(s), index ? 1 : 0];
});
back.push(S(G.solidOf(CONE, DECKF, P, { smooth: { slope: coneSlope, rows: 5 } }), { tone: "hi", inner: Ln([13, 22].map((s) => G.arcOf(DECKF, s, cone(s), P, { slope: coneSlope(s) })).join(""), { tone: "faint" }) }));
record(A.solid(DECKF, CONE, { name: "stand.cone", ...FRAME }));

const DELUGE = G.arcPoints(DECKF, 6.4, 68, 0, Math.PI * 2, 72);
for (const part of tubePieces(DELUGE, 2.1, P, { maxLength: 400, spacing: 2, closed: true })) back.push(tubeSvg(part, { tone: "mid" }));
back.push(Dots(G.dotsOf(DECKF, 8.6, 68, 36, P, { all: true }), { size: 0.5, tone: "hi" }));
record(A.tube(DELUGE, 2.1, { name: "stand.deluge", ...FRAME }));
const TEE_DEG = -12;
const TEE_R = 2.9;
const TEE_F = G.frameAlong(G.pointOf(DECKF, 6.4, 68, TEE_DEG * DEG), G.tangentOf(DECKF, TEE_DEG * DEG));
back.push(S(G.disc(-2.4, 2.4, TEE_R, TEE_F, P, { bevel: 0.3 }), { tone: "mid" }));
record(A.disc(TEE_F, -2.4, 2.4, TEE_R, { name: "stand.deluge-tee", ...FRAME }));
const FEED = G.fillet([G.pointOf(DECKF, 6.4, 68 + TEE_R - 0.2, TEE_DEG * DEG), [100, -14, 6.4], [139, -14, 6.4], [139, -14, 1.5]], 6);
for (const part of tubePieces(FEED, 2.1, P, { maxLength: 400, caps: [false, true] })) back.push(tubeSvg(part, { tone: "mid" }));
record(A.tube(FEED, 2.1, { name: "stand.deluge-feed", ...FRAME }));
back.push(S(k.slabOf({ x: 132, y: -22, w: 13, d: 16, r: 2 }, 0, 4, P, 3, 0.6), { tone: "mid" }));
back.push(Dots([at([135, -19, 4]), at([142, -9, 4])], { size: 0.45 }));

for (const [cx, cy] of COLUMN_AT) {
  const base = { x: cx - 23, y: cy - 23, w: 46, d: 46, r: 3 };
  back.push(S(k.slabOf(base, 0, 5, P, 3, 0.8), { tone: "mid" }));
  record(A.box([base.x, base.y, 0], [base.x + base.w, base.y + base.d, 5], { name: "stand.column-base", ...FRAME }));
  back.push(Dots(k.corners(base, 6).map(([x, y]) => at([x, y, 5])), { size: 0.55 }));
  back.push(Ln(k.corners(base, 6).map(([x, y]) => k.ring(x, y, 2.4, 5, P, 14)).join(""), { tone: "lo" }));
  const column = { x: cx - COLUMN / 2, y: cy - COLUMN / 2, w: COLUMN, d: COLUMN, r: 1.5 };
  back.push(S(k.slabOf(column, 5, BEAM_Z - 5, P, 3, 0.8), { tone: "mid" }));
  record(A.box([column.x, column.y, 5], [column.x + column.w, column.y + column.d, BEAM_Z], { name: "stand.column", ...FRAME }));
  const edge = [cx + COLUMN / 2 - 1.5 + 1.5 * Math.SQRT1_2, cy + COLUMN / 2 - 1.5 + 1.5 * Math.SQRT1_2];
  back.push(Ln(k.segment([...edge, 5], [...edge, BEAM_Z], P), { tone: "faint" }));
  for (const z of [124, 248, 372]) {
    const splice = k.insetPlan(column, -2.2);
    back.push(S(k.slabOf(splice, z, 13, P, 3, 0.5), { tone: "mid" }));
    record(A.box([splice.x, splice.y, z], [splice.x + splice.w, splice.y + splice.d, z + 13], { name: "stand.splice", ...FRAME }));
    back.push(Dots([at([splice.x + splice.w, cy - 6, z + 4]), at([splice.x + splice.w, cy + 6, z + 4]), at([splice.x + splice.w, cy - 6, z + 9]), at([splice.x + splice.w, cy + 6, z + 9]), at([cx - 6, splice.y + splice.d, z + 4]), at([cx + 6, splice.y + splice.d, z + 4]), at([cx - 6, splice.y + splice.d, z + 9]), at([cx + 6, splice.y + splice.d, z + 9])], { size: 0.42 }));
  }
}
const TRAY_X = COLUMN_AT[1][0] - COLUMN / 2 - 6;
back.push(S(k.slabOf({ x: TRAY_X, y: COLUMN_AT[1][1] + 2, w: 6, d: 9, r: 0.6 }, 28, BEAM_Z - 36, P, 2), { tone: "mid" }));
for (let z = 52; z < BEAM_Z - 20; z += 40) back.push(S(k.slabOf({ x: TRAY_X - 0.8, y: COLUMN_AT[1][1] + 1, w: 7.6, d: 11, r: 0.6 }, z, 3, P, 2), { tone: "mid" }));
const LADDER_Y = COLUMN_AT[0][1] + COLUMN / 2;
for (const x of [COLUMN_AT[0][0] - 9, COLUMN_AT[0][0] + 9]) back.push(S(k.slabOf({ x: x - 1.3, y: LADDER_Y + 8, w: 2.6, d: 2.6, r: 0.6 }, 18, BEAM_Z - 52, P, 2), { tone: "mid", crease: "none" }));
for (let z = 40; z < BEAM_Z - 38; z += 28) back.push(Ln(k.segment([COLUMN_AT[0][0] - 9, LADDER_Y + 10.6, z], [COLUMN_AT[0][0] + 9, LADDER_Y + 10.6, z], P), { tone: "mid" }));
for (const z of [52, BEAM_Z - 56]) for (const x of [COLUMN_AT[0][0] - 9, COLUMN_AT[0][0] + 9]) back.push(S(k.slabOf({ x: x - 1, y: LADDER_Y, w: 2, d: 9, r: 0.4 }, z, 2, P, 2), { tone: "mid", crease: "none" }));


const bell = (s) => 36 + 29 * (1 - Math.pow(s / 120, 1.6));
const bellSlope = (s) => (-29 * 1.6 * Math.pow(s / 120, 0.6)) / 120;
const BELL_AT = [3.2, 14, 28, 44, 60, 76, 92, 106, 116];
const BELL_STEPS = 96;
const BELL_SKIRT = BELL_AT.length - 2;
turned("nozzle", ENGINE, [[0, 66.4], [3.2, 66.4]], {}, { bevel: 0.6, steps: BELL_STEPS });
const BELL = G.bandsOf([[BELL_AT[0], bell(BELL_AT[0])], [BELL_AT[BELL_AT.length - 1], bell(BELL_AT[BELL_AT.length - 1])]], BELL_AT.slice(1, -1), ENGINE, P, { radius: bell, slope: bellSlope, steps: BELL_STEPS, rows: 8 });
BELL.bands.forEach((band, index) => {
  const seam = index === 2 || index === 5 ? Ln(band.seam, { tone: "faint" }) : "";
  put("nozzle", ex(ENGINE, (band.s0 + band.s1) / 2), S(index < BELL_SKIRT ? band.paths : { ...band.paths, outline: band.outline }, { tone: "hi", inner: seam }), { name: `nozzle.bell${index}`, shapes: [record(A.solid(ENGINE, band.profile, { radius: bell, name: `nozzle.bell${index}` }))] });
});
const BELL_SIDES = BELL.sidesOf(BELL_AT[0], BELL_AT[BELL_SKIRT], { ends: "first", count: 72 });
put("nozzle", ex(ENGINE, BELL_AT[BELL_SKIRT] - 1), Ln(BELL_SIDES.d, { tone: "hi" }), { bias: 0.2, name: "nozzle.sides", shapes: BELL_SIDES.points.map((points) => A.tube(points, 0.4 / KS)) });
put(
  "nozzle",
  ex(ENGINE, 118),
  S(G.ringBand(116, 120, 30.5, 38, ENGINE, P), { tone: "hi" }) +
    `<path class="rp-void" d="${G.circleOf(ENGINE, 120, 30.5, P)}"/>` +
    Ln(G.arcOf(ENGINE, 120, 30.5, P, { inward: true, least: -1 }), { tone: "lo" }) +
    Dots(G.dotsOf(ENGINE, 120, 36.4, 40, P, { all: true }), { size: 0.38, tone: "mid" }),
  { name: "nozzle.lip", shapes: [record(A.ring(ENGINE, 116, 120, 30.5, 38, { name: "nozzle.lip" }))] },
);
const MANIFOLD_S = 109;
const MANIFOLD_TUBE = 4.6;
const TEE = 6.6;
const DOWN_R = 4.6;
const bellGap = (r) => Math.min(...Array.from({ length: 241 }, (_, index) => Math.hypot(index / 2 - MANIFOLD_S, bell(index / 2) - r)));
let MANIFOLD_R = 40;
while (bellGap(MANIFOLD_R) < TEE + 0.3) MANIFOLD_R += 0.05;
const PORTS = [12, 132, 252];
const MANIFOLD = G.arcPoints(ENGINE, MANIFOLD_S, MANIFOLD_R, PORTS[0] * DEG, (PORTS[0] + 360) * DEG, 120);
const MANIFOLD_LENGTH = G.pathLength(MANIFOLD);
const teeGap = Math.sqrt(TEE ** 2 - MANIFOLD_TUBE ** 2);
const portAt = (deg) => ((deg - PORTS[0]) / 360) * MANIFOLD_LENGTH;
tubeItems("nozzle", MANIFOLD, MANIFOLD_TUBE, { name: "nozzle.manifold", maxLength: 22, spacing: 1.2, closed: true, touch: ["nozzle.bell*"], gaps: [[0, teeGap], [MANIFOLD_LENGTH - teeGap, MANIFOLD_LENGTH], ...PORTS.slice(1).map((deg) => [portAt(deg) - teeGap, portAt(deg) + teeGap])] });
for (const deg of PORTS) ballAt("nozzle", polar(deg, MANIFOLD_R, MANIFOLD_S), TEE, { tone: "hi", name: "nozzle.tee", owner: "nozzle.manifold" });
for (const deg of PORTS.slice(1)) {
  const F = G.frameAlong(polar(deg, MANIFOLD_R, MANIFOLD_S), dir(deg));
  const s0 = Math.sqrt(TEE ** 2 - 2.6 ** 2);
  put("nozzle", G.pointOf(F, 7, 0, 0), S(G.disc(s0, 7.6, 2.6, F, P), { tone: "hi" }), { name: "nozzle.stub", shapes: [record(A.disc(F, s0, 7.6, 2.6, { name: "nozzle.stub", owner: "nozzle.manifold" }))] });
  put("nozzle", G.pointOf(F, 8.5, 0, 0), S(G.disc(7.6, 9.4, 4.4, F, P), { tone: "hi" }) + Dots(G.dotsOf(F, 9.4, 3.4, 6, P, { all: true }), { size: 0.38, tone: "mid" }), { name: "nozzle.blind", shapes: [record(A.disc(F, 7.6, 9.4, 4.4, { name: "nozzle.blind" }))] });
}
for (const deg of [72, 192, 312]) {
  const base = polar(deg, bell(MANIFOLD_S) - 0.1, MANIFOLD_S);
  const F = G.frameAlong(base, dir(deg));
  const reach = MANIFOLD_R - MANIFOLD_TUBE + 0.12 - (bell(MANIFOLD_S) - 0.1);
  put("nozzle", G.pointOf(F, reach / 2, 0, 0), S(G.disc(0, reach, 0.9, F, P), { tone: "mid" }), { name: "nozzle.pad", shapes: [record(A.disc(F, 0, reach, 0.9, { name: "nozzle.pad", owner: "nozzle.manifold" }))] });
}
for (const deg of [60, 200]) boss("nozzle", polar(deg, bell(40), 40), dir(deg), 1.8, 2.6);
boss("nozzle", polar(30, bell(70), 70), dir(30), 1.6, 2.2);
const LIP = G.arcOf(ENGINE, 0, 66.4, P, { least: -0.05 });
for (const deg of [96, 156]) boss("nozzle", polar(deg, bell(88) - 0.15, 88), G.unit3(G.sub3(dir(deg), [0, 0, bellSlope(88)])), 2.4, 3.2, { name: "nozzle.pad" });


const OTP_REACH = 7;
const OTP_FLANGE_R = 9.4;
const FTP_REACH = 7.5;
const FTP_FLANGE_R = 8.6;
const radiusOn = (profile, s) => {
  let index = 1;
  while (index < profile.length - 1 && profile[index][0] < s) index++;
  const [s0, r0] = profile[index - 1];
  const [s1, r1] = profile[index];
  return r0 + ((r1 - r0) * (s - s0)) / (s1 - s0 || 1);
};
const slopeOn = (profile, s) => (radiusOn(profile, s + 0.01) - radiusOn(profile, s - 0.01)) / 0.02;
const onBody = (F, profile, deg, s) => ({ at: G.pointOf(F, s, radiusOn(profile, s), deg * DEG), normal: G.unit3(G.sub3(G.radialOf(F, deg * DEG), G.mul3(F.a, slopeOn(profile, s)))) });

turned("chamber", ENGINE, [[120, 38], [123.5, 38]], {}, { extra: Dots(G.dotsOf(ENGINE, 123.5, 36.4, 40, P, { all: true }), { size: 0.38, tone: "mid" }), bevel: 0.5 });
const throat = (s) => Math.sqrt(24.2 ** 2 + ((s < 146 ? 0.997 : 0.95) * (s - 146)) ** 2);
const throatSlope = (s) => ((s < 146 ? 0.994 : 0.9025) * (s - 146)) / throat(s);
const THROAT = [...Array.from({ length: 15 }, (_, index) => 123.5 + (22.5 * index) / 15), ...Array.from({ length: 11 }, (_, index) => 146 + (16 * index) / 10)].map((s, index, all) => [s, throat(s), index && index < all.length - 1 ? 1 : 0]);
turned("chamber", ENGINE, THROAT, {}, { smooth: { slope: throatSlope, rows: 5 }, inner: G.arcOf(ENGINE, 146, throat(146), P) });
const JACKETS = [[162, 171, 167], [174.2, 186, 180], [189.2, 194, 191.6]].map(([s0, s1, seam]) => band("chamber", ENGINE, s0, s1, 29.4, {}, { name: "chamber.jacket", inner: G.arcOf(ENGINE, seam, 29.4, P) }));
const RINGS = [171, 186].map((s) => band("chamber", ENGINE, s, s + 3.2, 31.6, {}, { name: "chamber.ring", extra: Dots(G.dotsOf(ENGINE, s + 3.2, 30.4, 24, P, { all: true, least: 0 }), { size: 0.34, tone: "lo" }) }));
const HEAD = band("chamber", ENGINE, 194, 205, 33.4, {}, { bias: 0.4, extra: Dots(G.dotsOf(ENGINE, 200.5, 33.4, 30, P, { fade: FADE }), { size: 0.4, tone: "mid" }) });
const INJECTOR = turned("chamber", ENGINE, [[205, 37.5], [210, 37.5]], {}, { bevel: 0.6, extra: Dots(G.dotsOf(ENGINE, 210, 35.6, 36, P, { all: true }), { size: 0.42, tone: "mid" }) });
const DOME_PROFILE = [[210, 34.4], [214, 34, 1], [218, 32, 1], [222, 27.5]];
const DOME = turned("chamber", ENGINE, DOME_PROFILE, {}, { extra: Ln(G.circleOf(ENGINE, 222, 23.5, P), { tone: "faint" }) });
const domeAt = (deg, s) => onBody(ENGINE, DOME_PROFILE, deg, s);
const JACKET_BOSS = Object.fromEntries([70, 110, 250].map((deg) => [deg, boss("chamber", polar(deg, 29.4, 180), dir(deg), 1.7, 3, { host: JACKETS[1] })]));
boss("chamber", polar(-150, 33.4, 200), dir(-150), 2.4, 3.6, { host: HEAD });
const DOME_BOSS = Object.fromEntries([-100, 175].map((deg) => {
  const { at: point, normal } = domeAt(deg, 216);
  return [deg, { face: boss("chamber", point, normal, 1.5, 2.6, { host: DOME }), normal }];
}));
boss("chamber", domeAt(28, 219).at, domeAt(28, 219).normal, 1.5, 2.4, { host: DOME });


turned("orpb", ENGINE, [[222, 25.5], [225.5, 25.5]], {}, { extra: Dots(G.dotsOf(ENGINE, 225.5, 24, 24, P, { all: true }), { size: 0.4, tone: "mid" }) });
const ORPB_BODY = band("orpb", ENGINE, 225.5, 242, 22.2, {}, { inner: [230, 236].map((s) => G.arcOf(ENGINE, s, 22.2, P)).join("") });
turned("orpb", ENGINE, [[242, 25.5], [246, 25.5]], {}, { extra: Dots(G.dotsOf(ENGINE, 246, 24, 24, P, { all: true }), { size: 0.4, tone: "mid" }) });
const IGNITER_DEG = 200;
const ORPB_IGNITER = boss("orpb", polar(IGNITER_DEG, 22.2, 233), dir(IGNITER_DEG), 2.6, 4.4, { host: ORPB_BODY, ring: false });
const ORPB_PLUG = boss("orpb", ORPB_IGNITER, dir(IGNITER_DEG), 2, 4);
const ORPB_EXCITER = boss("orpb", polar(100, 22.2, 228.5), dir(100), 1.3, 2.4, { host: ORPB_BODY, name: "orpb.exciter" });
const ORPB_SENSE = boss("orpb", polar(20, 22.2, 236), dir(20), 2, 3.2, { host: ORPB_BODY, name: "orpb.sense-boss" });
boss("orpb", polar(70, 22.2, 238), dir(70), 1.5, 2.6, { host: ORPB_BODY });


const OTP_RING = turned("otp", ENGINE, [[246, 25], [248, 27.4], [257, 27.4], [259, 24.5]], {}, {
  extra: Dots(G.dotsOf(ENGINE, 257, 27.4, 36, P, { fade: FADE }), { size: 0.4, tone: "mid" }),
  inner: G.arcOf(ENGINE, 252.5, 27.4, P),
});
band("otp", ENGINE, 259, 283, 20.6, {}, { name: "otp.housing", inner: [263, 279].map((s) => G.arcOf(ENGINE, s, 20.6, P)).join("") });
const VOLUTE = G.spiral(ENGINE, 271, 20.6 + 2.4 + 0.3, 20.6 + 6.6 + 0.3, -160 * DEG, 112 * DEG, 64);
const VOLUTE_END = VOLUTE[VOLUTE.length - 1];
const voluteTan = G.unit3(G.sub3(VOLUTE_END, VOLUTE[VOLUTE.length - 2]));
const OTP_OUTLET = G.add3(VOLUTE_END, G.mul3(voluteTan, OTP_REACH));
tubeItems("otp", [...VOLUTE, OTP_OUTLET], (t) => 2.4 + 4.2 * t, { name: "otp.volute", maxLength: 18, spacing: 1, bias: 0.3, touch: ["otp.housing"], ends: [false, true] });
const OTP_FLANGE_T = 2.6;
flangeAt("otp", G.add3(OTP_OUTLET, G.mul3(voluteTan, OTP_FLANGE_T / 2)), voluteTan, OTP_FLANGE_R, OTP_FLANGE_T, { bolts: 8, bias: 0.4, name: "otp.outlet" });
const LOX_START = G.add3(OTP_OUTLET, G.mul3(voluteTan, OTP_FLANGE_T));
turned("otp", ENGINE, [[283, 16.5], [286, 16.5]], {}, { extra: Dots(G.dotsOf(ENGINE, 286, 15, 16, P, { all: true }), { size: 0.38, tone: "mid" }) });
band("otp", ENGINE, 286, 292, 12.6);
const LUG_BALL = 3.6;
const lugBall = (foot) => polar(foot, 29.6 + LUG_BALL, 252.5);
for (const [, deg] of LUGS) {
  const F = G.frameAlong(polar(deg, 0, 252.5), dir(deg));
  const start = Math.sqrt(27.4 ** 2 - 2 ** 2);
  put("otp", polar(deg, 28.5, 252.5), S(G.disc(start, 29.6, 2, F, P), { tone: "mid" }), { bias: 0.1, name: "otp.lug", shapes: [record(A.disc(F, start, 29.6, 2, { name: "otp.lug", seated: true, cut: OTP_RING }))] });
}
const OTP_SENSE = boss("otp", polar(80, 27.4, 252.5), dir(80), 1.7, 2.4, { host: OTP_RING });
const PURGE_C = boss("otp", polar(-120, 27.4, 252.5), dir(-120), 2.2, 3, { host: OTP_RING });
const PRESS_LOX = boss("otp", polar(200, 27.4, 252.5), dir(200), 2.7, 2, { host: OTP_RING });
const PURGE_F = boss("otp", polar(-100, 27.4, 252.5), dir(-100), 2, 2.4, { host: OTP_RING });


const puck = (s) => 18.4 - (s < 297.5 ? 5 : 3.4) * (Math.abs(s - 297.5) / 5.5) ** 4;
const puckSlope = (s) => ((s < 297.5 ? 5 : -3.4) * 4 * Math.abs(s - 297.5) ** 3) / 5.5 ** 4;
const PUCK = Array.from({ length: 23 }, (_, index) => {
  const s = 292 + (11 * index) / 22;
  return [s, puck(s), index && index < 22 ? 1 : 0];
});
const GIMBAL = turned("gimbal", ENGINE, PUCK, {}, { smooth: { slope: puckSlope, rows: 3 }, inner: G.arcOf(ENGINE, 297.5, 18.4, P) });
for (const deg of [AZIMUTH - 45, AZIMUTH + 45, AZIMUTH + 135, AZIMUTH - 135]) boss("gimbal", polar(deg, 18.4, 297.5), dir(deg), 1.9, 2.8, { bias: 0.3, host: GIMBAL });
turned("gimbal", ENGINE, [[303, 24], [310, 24]], {}, { bevel: 0.8, extra: Dots(G.dotsOf(ENGINE, 310, 21.2, 18, P, { all: true }), { size: 0.45, tone: "mid" }) + Ln(G.circleOf(ENGINE, 310, 15, P), { tone: "faint" }) });


const FTP_LOW_PROFILE = [[166, 3.5], [168, 8, 1], [171, 10.6, 1], [175, 11.6], [194, 11.6]];
const FTP_LOW = turned("ftp", FTP, FTP_LOW_PROFILE, {}, { inner: [180, 188].map((s) => G.arcOf(FTP, s, 11.6, P)).join("") });
const FTP_RING = turned("ftp", FTP, [[194, 15], [198, 15]], {}, { extra: Dots(G.dotsOf(FTP, 198, 13.6, 18, P, { all: true }), { size: 0.4, tone: "mid" }) });
const FTP_TURBINE = turned("ftp", FTP, [[198, 16], [200, 18.4], [214, 18.4], [216, 16]], {}, { extra: Dots(G.dotsOf(FTP, 200, 18.4, 28, P, { fade: FADE }), { size: 0.4, tone: "mid" }), inner: G.arcOf(FTP, 207, 18.4, P) });
const FTP_NECK = band("ftp", FTP, 216, 226, 11.6, {}, { extra: Faded(G.ribsOf(FTP, 216, 226, 11.6, 18, P, { fade: [0.1, 0.55] }), { tone: "faint" }) });
const FTP_HOUSING = band("ftp", FTP, 226, 250, 15.2, {}, { name: "ftp.housing", inner: [230, 246].map((s) => G.arcOf(FTP, s, 15.2, P)).join("") });
const FVOLUTE = G.spiral(FTP, 238, 15.2 + 2 + 0.3, 15.2 + 5.6 + 0.3, -140 * DEG, 36 * DEG, 48);
const FVOLUTE_END = FVOLUTE[FVOLUTE.length - 1];
const fvTan = G.unit3(G.sub3(FVOLUTE_END, FVOLUTE[FVOLUTE.length - 2]));
const FTP_OUTLET = G.add3(FVOLUTE_END, G.mul3(fvTan, FTP_REACH));
tubeItems("ftp", [...FVOLUTE, FTP_OUTLET], (t) => 2 + 3.6 * t, { name: "ftp.volute", maxLength: 16, spacing: 1, bias: 0.3, touch: ["ftp.housing"], ends: [false, true] });
const FTP_FLANGE_T = 2.4;
flangeAt("ftp", G.add3(FTP_OUTLET, G.mul3(fvTan, FTP_FLANGE_T / 2)), fvTan, FTP_FLANGE_R, FTP_FLANGE_T, { bolts: 8, bias: 0.4, name: "ftp.outlet" });
const FUEL_START = G.add3(FTP_OUTLET, G.mul3(fvTan, FTP_FLANGE_T));
band("ftp", FTP, 250, 262, 10.2, {}, { inner: G.arcOf(FTP, 256, 10.2, P) });
turned("ftp", FTP, [[262, 13.6], [265, 13.6]], {}, { bevel: 0.5, extra: Dots(G.dotsOf(FTP, 265, 12, 12, P, { all: true }), { size: 0.4, tone: "mid" }) });
const FRPB_IGNITER = boss("ftp", ftp(70, 11.6, 182), dir(70), 2.4, 4, { host: FTP_LOW, ring: false });
const FRPB_PLUG = boss("ftp", FRPB_IGNITER, dir(70), 1.9, 3.6, { name: "ftp.igniter-plug" });
const FRPB_EXCITER_AT = onBody(FTP, FTP_LOW_PROFILE, 40, 173);
const FRPB_EXCITER = boss("ftp", FRPB_EXCITER_AT.at, FRPB_EXCITER_AT.normal, 1.3, 2.4, { host: FTP_LOW, name: "ftp.exciter" });
const FTP_SENSE = boss("ftp", ftp(100, 18.4, 207), dir(100), 1.7, 2.6, { host: FTP_TURBINE });
const FTP_PUMP_SENSE = boss("ftp", ftp(110, 15.2, 233), dir(110), 1.7, 2.4, { host: FTP_HOUSING });
const PICKUP_DEG = 60;
const FTP_PICKUP = boss("ftp", ftp(PICKUP_DEG, 11.6, 221), dir(PICKUP_DEG), 1.4, 2.4, { name: "ftp.pickup", host: FTP_NECK });
const balance = [ftp(250, 15.2, 229), ftp(250, 19, 229), ftp(250, 21, 221), ftp(250, 22, 212), ftp(250, 18.4, 212)];
pipe("ftp", balance, 1.2, { flangeR: 1.6, bend: 3, name: "ftp.balance" });

function strut(piece, [hostA, a, na], [hostB, b, nb], { move, r = 1.1, ball = 2.2, stem = 1.3, lift = 1.2 } = {}) {
  const id = nameFor(`${piece}.strut`);
  const reach = Math.sqrt(ball ** 2 - stem ** 2);
  const [ca, cb] = [[hostA, a, na], [hostB, b, nb]].map(([host, point, normal]) => {
    const own = host.piece ?? piece;
    const face = boss(own, point, normal, stem, lift, { host, move: own === piece ? move : undefined, name: `${own}.ball-stud`, owner: `${id}.stud`, ring: false });
    const centre = G.add3(face, G.mul3(normal, reach));
    ballAt(own, centre, ball, { move: own === piece ? move : undefined, tone: "hi", name: `${own}.ball-stud`, owner: `${id}.stud` });
    return centre;
  });
  const axis = G.unit3(G.sub3(cb, ca));
  const length = G.len3(G.sub3(cb, ca));
  const F = G.frameAlong(ca, axis);
  const end = Math.sqrt(ball ** 2 - r ** 2);
  put(piece, G.lerp3(ca, cb, 0.5), S(G.disc(end, length - end, r, F, P), { tone: "hi", inner: Ln(G.arcOf(F, length * 0.5, r, P), { tone: "faint" }) }), { move, name: id, shapes: [record(A.disc(F, end, length - end, r, { name: id, owner: id }))] });
}
const STRUT_LOW = onBody(FTP, FTP_LOW_PROFILE, 214, 172.4);
strut("ftp", [FTP_HOUSING, ftp(196, 15.2, 244), dir(196)], [OTP_RING, polar(-24, 27.4, 250.6), dir(-24)]);
strut("ftp", [FTP_TURBINE, ftp(116, 18.4, 205), dir(116)], [INJECTOR, polar(8, 37.5, 207.5), dir(8)]);
strut("ftp", [FTP_LOW, STRUT_LOW.at, STRUT_LOW.normal], [RINGS[0], polar(-56, 31.6, 172.6), dir(-56)]);


const HOT_S = 199.4;
const HOT_R = 3.6;
const HOT_PAD = 5.2;
const HOT_A = boss("ftp", ftp(160, 18.4, HOT_S), dir(160), HOT_PAD, 1, { host: [FTP_TURBINE, FTP_RING], ring: false, name: "ftp.hot-pad" });
const HOT_B = boss("chamber", polar(FTP_DEG, 33.4, HOT_S), dir(FTP_DEG), HOT_PAD, 1, { host: HEAD, ring: false, name: "chamber.hot-pad" });
pipe("hotgas", [HOT_A, HOT_B], HOT_R, { flangeR: 1.3, bands: [0.5], maxLength: 12, name: "hotgas.duct", free: [true, true] });


const DOWN_ELBOW = G.add3(FUEL_START, G.mul3(fvTan, 30));
const DOWN_STUB = [MANIFOLD_S + Math.sqrt(TEE ** 2 - DOWN_R ** 2), MANIFOLD_S + 7.6, MANIFOLD_S + 8.8];
{
  const F = G.frameAlong(polar(PORTS[0], MANIFOLD_R, 0), [0, 0, 1]);
  put("nozzle", polar(PORTS[0], MANIFOLD_R, (DOWN_STUB[0] + DOWN_STUB[1]) / 2), S(G.disc(DOWN_STUB[0], DOWN_STUB[1], DOWN_R, F, P), { tone: "hi" }), { name: "nozzle.stub", shapes: [record(A.disc(F, DOWN_STUB[0], DOWN_STUB[1], DOWN_R, { name: "nozzle.stub", owner: "nozzle.manifold" }))] });
  put("nozzle", polar(PORTS[0], MANIFOLD_R, (DOWN_STUB[1] + DOWN_STUB[2]) / 2), S(G.disc(DOWN_STUB[1], DOWN_STUB[2], DOWN_R * 1.4, F, P), { tone: "hi" }) + Dots(G.dotsOf(F, DOWN_STUB[2], DOWN_R * 1.2, 8, P, { all: true }), { size: 0.38, tone: "mid" }), { name: "nozzle.port-flange", shapes: [record(A.disc(F, DOWN_STUB[1], DOWN_STUB[2], DOWN_R * 1.4, { name: "nozzle.port-flange", owner: "nozzle.manifold" }))] });
}
const DOWN_ROUTE = [FUEL_START, DOWN_ELBOW, [DOWN_ELBOW[0], DOWN_ELBOW[1], Z_EXIT + 196], polar(12, 60, 152), polar(12, MANIFOLD_R, 130), polar(12, MANIFOLD_R, DOWN_STUB[2])];
const DOWN = pipe("downcomer", DOWN_ROUTE, DOWN_R, { bend: 1.6, flangeR: [FTP_FLANGE_R / DOWN_R, 1.4], bands: [0.5, 0.58, 0.66], bellows: [0.24, 0.3, 5], valve: [12.4, 3.6], maxLength: 18, name: "downcomer.duct", free: [true, true] });
const MFV = valveOn("downcomer", DOWN, 12.4, 3.6);


const LOX_S = 233.75;
const LOX_DEG = 150;
const LOX_FACE = boss("orpb", polar(LOX_DEG, 22.2, LOX_S), dir(LOX_DEG), 7, 1.2, { host: ORPB_BODY, ring: false, name: "orpb.lox-pad" });
const LOX = pipe("lox", [LOX_START, G.add3(LOX_START, G.mul3(voluteTan, 6)), polar(140, 52, 270), polar(140, 52, LOX_S), polar(LOX_DEG, 40, LOX_S), LOX_FACE], 5.6, {
  bend: 1.5,
  flangeR: [OTP_FLANGE_R / 5.6, 1.2],
  valve: [0.5, 4.2],
  maxLength: 14,
  name: "lox.duct",
  free: [true, true],
});
const LOX_RUN = R.tubes.filter((tube) => tube.owner === LOX.owner);
const MOV = valveOn("lox", LOX, 0.5, 4.2, { side: dir(110) });


const LINES = [];
function line(points, r, move, options = {}) {
  LINES.push(points);
  return pipe("plumbing", points, r, { move, flangeR: 1.7, maxLength: 16, name: "plumbing.line", ...options });
}
const ringAt = (s, r, from, to, steps = 0) => {
  const count = steps || Math.max(2, Math.ceil(Math.abs(to - from) / 12));
  return Array.from({ length: count + 1 }, (_, index) => polar(from + ((to - from) * index) / count, r, s));
};
const tubeAt = (runs, point) => runs.reduce((best, run) => {
  const d = A.sdf(run, point);
  return Math.abs(d) < Math.abs(best.d) ? { run, d } : best;
}, { run: null, d: Infinity }).run;

const JACKET_MOVE = out(-120, 50, -10);
const JACKET_S = 199;
const JACKET_FROM = boss("chamber", polar(-48, 33.4, JACKET_S), dir(-48), 4.4, 1, { host: HEAD, ring: false, name: "chamber.jacket-pad" });
const JACKET_TO = boss("ftp", ftp(168, 11.6, 181), dir(168), 4.4, 1, { host: FTP_LOW, ring: false, name: "ftp.jacket-pad" });
const JACKET_TEE = polar(-48, 41, JACKET_S);
const JACKET_BALL = 4.2;
const jacketLine = line([JACKET_FROM, polar(-48, 50, JACKET_S), G.add3(JACKET_TO, G.mul3(dir(168), 13)), JACKET_TO], 3, JACKET_MOVE, { name: "plumbing.jacket-to-frpb", bands: [0.55], flangeR: [1.3, 1.4], free: [true, true], gaps: [[G.len3(G.sub3(JACKET_TEE, JACKET_FROM)) - Math.sqrt(JACKET_BALL ** 2 - 9), G.len3(G.sub3(JACKET_TEE, JACKET_FROM)) + Math.sqrt(JACKET_BALL ** 2 - 9)]] });
ballAt("plumbing", JACKET_TEE, JACKET_BALL, { move: JACKET_MOVE, tone: "hi", name: "plumbing.jacket-tee", owner: jacketLine.owner });
const ORPB_FEED = boss("orpb", polar(-96, 22.2, 236), dir(-96), 3.2, 0.8, { host: ORPB_BODY, ring: false, name: "orpb.jacket-pad" });
line([G.add3(JACKET_TEE, [0, 0, Math.sqrt(JACKET_BALL ** 2 - 2.1 ** 2)]), G.add3(JACKET_TEE, [0, 0, 6]), polar(-52, 41, 209), polar(-70, 42, 216), polar(-92, 36, 230), polar(-96, 31, 236), ORPB_FEED], 2.1, JACKET_MOVE, { name: "plumbing.jacket-to-orpb", bands: [0.5], flanges: [false, true], flangeR: 1.4, free: [false, true] });

const TAP_S = 199.5;
const TAP_R = 41.5;
const TAP_AT = polar(LOX_DEG, 34, LOX_S - 5.6);
const LOX_FRPB_MOVE = out(86, 50, -16);
const LOX_TAP = boss("lox", TAP_AT, [0, 0, -1], 3.1, 1, { host: tubeAt(LOX_RUN, TAP_AT), ring: false, name: "lox.tap-pad" });
const FRPB_FEED_AT = onBody(FTP, FTP_LOW_PROFILE, 112, 172.6);
const FRPB_FEED = boss("ftp", FRPB_FEED_AT.at, FRPB_FEED_AT.normal, 3.1, 0.8, { host: FTP_LOW, ring: false, name: "ftp.feed-pad" });
line([LOX_TAP, polar(LOX_DEG, 34, 222), polar(LOX_DEG - 4, TAP_R, 210.5), ...ringAt(TAP_S, TAP_R, LOX_DEG - 8, 2), polar(-6, 50, 192), G.add3(FRPB_FEED, G.mul3(FRPB_FEED_AT.normal, 11)), FRPB_FEED], 2.1, LOX_FRPB_MOVE, { name: "plumbing.lox-to-frpb", bands: [0.18, 0.4, 0.62, 0.84], flangeR: 1.4, free: [true, true] });

const PRESS_LOX_MOVE = out(205, 30, -8);
line([PRESS_LOX, polar(200, 33, 252.5), polar(198, 36, 262), polar(188, 37, 285), polar(150, 46, 310), polar(135, 50, TOP + 13), polar(135, 50, TOP + 21)], 1.5, PRESS_LOX_MOVE, { name: "plumbing.press-lox", bands: [0.25, 0.5, 0.75], coupling: true, free: [true, true] });
line([polar(-62, 29.4, 182.5), polar(-62, 49.5, 182.5), polar(-58, 49.5, 222), polar(-52, 50, 270), polar(-46, 49, 300), polar(-45, 46, TOP + 13), polar(-45, 46, TOP + 21)], 1.5, out(-70, 46, -30), { name: "plumbing.press-ch4", bands: [0.3, 0.6], coupling: true, flangeR: [1.5, 1.7], free: [true, true] });
line([PURGE_C, polar(-120, 34, 252.5), polar(-120, 38, 250), polar(-140, 40, 228), G.add3(domeAt(-150, 212).at, G.mul3(domeAt(-150, 212).normal, 4)), domeAt(-150, 212).at], 1.1, out(-140, 45, -10), { name: "plumbing.purge-chamber", bands: [0.5], free: [true, true] });
line([PURGE_F, polar(-100, 40, 252.5), polar(-72, 44, 238), polar(-52, 44.5, 229), polar(-40, 52, 222), G.add3(ftp(200, 18.4, 210), G.mul3(dir(200), 8)), ftp(200, 18.4, 210)], 1.1, out(-80, 35, -15), { name: "plumbing.purge-ftp", bands: [0.35, 0.7], free: [true, true] });
const INLET_AT = G.add3(polar(LOX_DEG, 30, LOX_S), G.mul3(dir(LOX_DEG + 90), 5.6));
const LOX_INLET = boss("lox", INLET_AT, dir(LOX_DEG + 90), 2, 0.8, { host: tubeAt(LOX_RUN, INLET_AT), ring: false, name: "lox.inlet-pad" });
line([ORPB_PLUG, G.add3(ORPB_PLUG, G.mul3(dir(IGNITER_DEG), 3)), G.add3(LOX_INLET, G.mul3(dir(LOX_DEG + 90), 3)), LOX_INLET], 0.9, out(160, 40, -40), { name: "plumbing.igniter-orpb", flangeR: 2, bend: 3, free: [true, true] });
const IGNITER_FRPB_TURN = ftp(62, 26.5, 189);
const IGNITER_FRPB_TIP = elbow("ftp", FRPB_PLUG, dir(70), 1.9, IGNITER_FRPB_TURN, 2.3, 0.9 * 1.3, { name: "ftp.igniter-elbow", owner: "ftp.igniter-plug" });
line([IGNITER_FRPB_TIP, IGNITER_FRPB_TURN, ftp(52, 24, 196), ftp(52, 15, 196)], 0.9, PIECES.ftp.move, { name: "plumbing.igniter-frpb", flangeR: [1.3, 2], bend: 4 });

const BLOCK_C = polar(52, 45, 228);
const BLOCK = { x: BLOCK_C[0] - 6, y: BLOCK_C[1] - 4, w: 12, d: 8, r: 1 };
const BLOCK_MOVE = PIECES.controller.move;
boxAt("plumbing", BLOCK, BLOCK_C[2] - 6, 12, {}, {
  name: "plumbing.block",
  move: BLOCK_MOVE,
  bias: 0.5,
  extra: Ln(k.planOutline(k.insetPlan(BLOCK, 1.2), BLOCK_C[2] + 6, P, 2), { tone: "faint" }) + Dots([0, 1, 2, 3].map((index) => at([BLOCK.x + 2 + index * 2.6, BLOCK.y + BLOCK.d, BLOCK_C[2] + 2])), { size: 0.5, tone: "hi" }),
});
const BLOCK_LOW = BLOCK_C[2] - 6;
const BLOCK_TOP = BLOCK_C[2] + 6;
const below = (x) => [[x, BLOCK.y + 10.5, BLOCK_LOW - 15], [x, BLOCK.y + 6, BLOCK_LOW - 7], [x, BLOCK.y + 6, BLOCK_LOW]];
const above = (x) => [[x, BLOCK.y + 4, BLOCK_TOP + 6], [x, BLOCK.y + 4, BLOCK_TOP]];
const SENSE_DEG = 40;
const SENSE_BOSS = boss("chamber", domeAt(SENSE_DEG, 219).at, domeAt(SENSE_DEG, 219).normal, 1.7, 1.2, { name: "chamber.sense-boss", host: DOME });
const MANIFOLD_RUNS = R.tubes.filter((tube) => tube.owner === "nozzle.manifold");
const MANIFOLD_TOP = polar(64, MANIFOLD_R, MANIFOLD_S + MANIFOLD_TUBE);
const MANIFOLD_SENSE = boss("nozzle", MANIFOLD_TOP, [0, 0, 1], 1.8, 0.8, { host: tubeAt(MANIFOLD_RUNS, MANIFOLD_TOP), ring: false, name: "nozzle.sense-pad" });
const SENSE0_FACE = boss("chamber", polar(56, 29.4, 180), dir(56), 1.3, 1.4, { host: JACKETS[1], name: "chamber.sense-port" });
const SENSE0_TEE = G.add3(SENSE0_FACE, G.mul3(dir(56), Math.sqrt(1.7 ** 2 - 1.3 ** 2)));
ballAt("chamber", SENSE0_TEE, 1.7, { tone: "hi", name: "chamber.sense-tee", owner: "chamber.sense-port" });
const SENSE0_OUT = G.unit3(G.add3(dir(56), [0, 0, 1]));
const SENSE0_TIP = G.add3(SENSE0_TEE, G.mul3(SENSE0_OUT, Math.sqrt(1.7 ** 2 - (0.75 * 1.3) ** 2)));
const senses = [
  [SENSE0_TIP, G.add3(SENSE0_TIP, G.mul3(SENSE0_OUT, 20)), ...below(BLOCK.x + 4)],
  [MANIFOLD_SENSE, polar(64, MANIFOLD_R, 124), polar(64, 50, 150), polar(62, 50, 190), ...below(BLOCK.x + 1.5)],
  [elbow("chamber", SENSE_BOSS, domeAt(SENSE_DEG, 219).normal, 1.7, [BLOCK.x + 6, BLOCK.y - 6, BLOCK_LOW + 5], 2.1, 0.75 * 1.3, { name: "chamber.sense-elbow", owner: "chamber.sense-boss" }), [BLOCK.x + 6, BLOCK.y - 6, BLOCK_LOW + 5], [BLOCK.x + 6, BLOCK.y, BLOCK_LOW + 5]],
  [FTP_SENSE, G.add3(FTP_SENSE, G.mul3(dir(100), 6)), polar(20, 62, 205), ...below(BLOCK.x + 9)],
  [OTP_SENSE, G.add3(OTP_SENSE, G.mul3(dir(80), 3)), polar(78, 44, 258), ...above(BLOCK.x + 2.5)],
  [elbow("orpb", ORPB_SENSE, dir(20), 2, polar(34, 36, 239), 2.4, 0.75 * 1.3, { name: "orpb.sense-elbow", owner: "orpb.sense-boss" }), polar(34, 36, 239), ...above(BLOCK.x + 6)],
  [FTP_PUMP_SENSE, G.add3(FTP_PUMP_SENSE, G.mul3(dir(110), 4)), polar(-5, 58, 258), polar(40, 44, 256), ...above(BLOCK.x + 9.5)],
];
senses.forEach((points, index) => {
  line(points, 0.75, BLOCK_MOVE, { name: `plumbing.sense${index}`, flangeR: [0, 2, 5].includes(index) ? [1.3, 2.2] : 2.2, bend: 4, flanges: [true, false], free: [true, false] });
  const end = points[points.length - 1];
  if (end[2] > BLOCK_C[2]) put("plumbing", end, Dots([at(end)], { size: 0.5, tone: "hi" }), { move: BLOCK_MOVE, bias: 0.4, name: `plumbing.port${index}`, shapes: [A.ball(end, 0.5 / KS)] });
});

const TRANSDUCERS = [
  { face: JACKET_BOSS[110], axis: dir(110), to: { host: RINGS[0], deg: 110, s: 172.6, r: 31.6 } },
  { face: JACKET_BOSS[250], axis: dir(250), to: { host: RINGS[0], deg: 250, s: 172.6, r: 31.6 } },
  { face: DOME_BOSS[-100].face, axis: DOME_BOSS[-100].normal, to: { host: INJECTOR, deg: -100, s: 207.5, r: 37.5 } },
];
TRANSDUCERS.forEach(({ face, axis, to }) => {
  const F = G.frameAlong(face, axis);
  const meridian = [[0, 0], [0, 1.9], [1.2, 2.2], [5.2, 2.2], [6, 1.5], [6, 0]];
  const id = nameFor("plumbing.transducer");
  put("plumbing", G.add3(face, G.mul3(axis, 3)), S(G.lathe(meridian, F, P, { steps: 20, smooth: { rows: 3 } }), { tone: "hi" }), { move: PIECES.chamber.move, bias: 0.3, name: id, shapes: [record(A.body(F, meridian, { name: id }))] });
  const tip = G.add3(face, G.mul3(axis, 6));
  const socket = boss("plumbing", polar(to.deg, to.r, to.s), dir(to.deg), 1.2, 2, { host: to.host, move: PIECES.chamber.move, name: "plumbing.socket" });
  const out1 = G.add3(tip, G.mul3(axis, 3));
  const near = G.add3(socket, G.mul3(dir(to.deg), 3));
  const reach = Math.max(Math.hypot(out1[0], out1[1]), Math.hypot(near[0], near[1])) + 2;
  cable("plumbing", [tip, out1, polar(to.deg, reach, out1[2] - Z_EXIT), polar(to.deg, reach, to.s), near, socket], { move: PIECES.chamber.move, r: 0.5, bend: 1.6, name: `plumbing.pigtail`, shells: [true, false] });
});


const BOX = { x: -24, y: 46, w: 26, d: 12, r: 1.6 };
const BOX_Z = Z_EXIT + 230;
const BOX_H = 28;
const CTRL = PIECES.controller.move;
boxAt("controller", BOX, BOX_Z, BOX_H, { inner: Ln(k.sideSeam(BOX, BOX_Z + BOX_H - 6, P, 3), { tone: "faint" }) }, {
  name: "controller.box",
  bevel: 0.8,
  extra:
    Ln(k.planOutline(k.insetPlan(BOX, 2), BOX_Z + BOX_H, P, 3), { tone: "faint" }) +
    Ln([0, 1, 2].map((row) => k.onTop([[BOX.x + 4, BOX.y + 3.4 + row * 2.4], [BOX.x + (row === 1 ? 14 : 19), BOX.y + 3.4 + row * 2.4]], BOX_Z + BOX_H, P)).join(""), { tone: "lo", free: true }) +
    Dots(k.corners(BOX, 2.2).map(([x, y]) => at([x, y, BOX_Z + BOX_H])), { size: 0.45 }),
});
const ARM = { x: -2, w: 4, z: Z_EXIT + 242.5, h: 3 };
const ARM_Y = Math.sqrt(25.5 ** 2 - (ARM.w / 2) ** 2);
boxAt("controller", { x: ARM.x, y: ARM_Y, w: ARM.w, d: BOX.y - ARM_Y, r: 0.4 }, ARM.z, ARM.h, {}, { bias: -1, bevel: 0.3, name: "controller.bracket" });
const JBOX = { x: -38, y: 46, w: 10, d: 10, r: 1.2 };
boxAt("controller", JBOX, Z_EXIT + 244, 12, {}, { name: "controller.jbox", extra: Ln(k.planOutline(k.insetPlan(JBOX, 1.4), Z_EXIT + 256, P, 2), { tone: "faint" }) + Dots(k.corners(JBOX, 1.8).map(([x, y]) => at([x, y, Z_EXIT + 256])), { size: 0.4 }) });
boxAt("controller", { x: JBOX.x + JBOX.w, y: JBOX.y + 3, w: BOX.x - JBOX.x - JBOX.w, d: 4, r: 0.3 }, Z_EXIT + 248, 4, {}, { bias: -0.5, bevel: 0.2, name: "controller.jbox-bracket" });
const CONNECTORS = [
  [BOX.x + 4, BOX_Z + 6],
  [BOX.x + 10, BOX_Z + 6],
  [BOX.x + 16, BOX_Z + 6],
  [BOX.x + 22, BOX_Z + 6],
  [BOX.x + 8, BOX_Z + 17],
  [BOX.x + 18, BOX_Z + 17],
];
const CONNECT_Y = BOX.y + BOX.d;
for (const [x, z] of CONNECTORS) {
  const F = G.frameOf([x, CONNECT_Y, z], [0, 1, 0], [1, 0, 0], [0, 0, 1]);
  const id = nameFor("controller.connector");
  put("controller", [x, CONNECT_Y + 2, z], S(G.disc(0, 4, 2.4, F, P), { tone: "hi" }) + Ln(G.circleOf(F, 4, 1.2, P, 14), { tone: "faint" }), { bias: 0.4, name: id, shapes: [record(A.disc(F, 0, 4, 2.4, { name: id }))] });
}
const plug = (index) => [CONNECTORS[index][0], CONNECT_Y + 4, CONNECTORS[index][1]];
const HARNESS_S = 214.6;
const UNDER = Z_EXIT + 222;
const DROP_Y = CONNECT_Y + 8.5;
const laneR = (lane) => 37.6 + lane * 1.4;
const laneS = (lane) => HARNESS_S + lane * 0.2;
const harnessRoute = (index, lane, to) => {
  const start = plug(index);
  const x = start[0];
  const entry = Math.acos(x / laneR(lane)) / DEG;
  const ring = ringAt(laneS(lane), laneR(lane), entry - 5, to.end, Math.max(2, Math.round((entry - 5 - to.end) / 10)));
  return [start, [x, DROP_Y, start[2]], [x, DROP_Y, UNDER], [x, Math.sqrt(laneR(lane) ** 2 - x * x), UNDER], ...ring, ...to.points];
};
const DROP_IN = G.unit3([-DOWN_ELBOW[0], -DOWN_ELBOW[1], 0]);
const besideDrop = (s) => [DOWN_ELBOW[0] + DROP_IN[0] * (DOWN_R * 1.26 + 1.1), DOWN_ELBOW[1] + DROP_IN[1] * (DOWN_R * 1.26 + 1.1), Z_EXIT + s];
const PICKUP_ROUTE = [polar(0, 58, 228), G.add3(FTP_PICKUP, G.add3(G.mul3(dir(PICKUP_DEG), 8), G.mul3(sideOn(dir(PICKUP_DEG)), 9))), G.add3(FTP_PICKUP, G.mul3(dir(PICKUP_DEG), 8)), FTP_PICKUP];
const HARNESS = [
  { name: "harness-frpb", index: 3, lane: 0, to: { end: 8, points: [polar(5, 50, 212), polar(2, 66, 196), ftp(96, 28, 186), ftp(56, 22, 174), G.add3(FRPB_EXCITER, G.mul3(FRPB_EXCITER_AT.normal, 7)), FRPB_EXCITER] } },
  { name: "harness-pickup", index: 2, lane: 1, to: { end: 12, points: PICKUP_ROUTE } },
  { name: "harness-mfv", index: 1, lane: 2, to: { end: 16, points: [polar(16, laneR(2), 268), [MFV.approach[0], MFV.approach[1], Z_EXIT + 268], MFV.approach, MFV.port] } },
  { name: "cable-block", index: 0, lane: 3, to: { end: 55, points: [[BLOCK.x + 2, BLOCK.y + 2.5, BLOCK_LOW]] } },
];
const CLAMPS = [88, 70, 34, 24].map((deg) => {
  const lanes = deg > 55 ? 4 : 3;
  return { deg, centre: polar(deg, (laneR(0) + laneR(lanes - 1)) / 2, (laneS(0) + laneS(lanes - 1)) / 2), axis: G.unit3(G.cross3(dir(deg), [0, 0, 1])), radius: (laneR(lanes - 1) - laneR(0)) / 2 + 1.25 };
});
for (const { name, index, lane, to } of HARNESS) cable("controller", harnessRoute(index, lane, to), { move: CTRL, bend: 4, name: `controller.${name}`, bundle: "harness", touch: ["controller.clamp*"], clamps: CLAMPS, shells: [false, true], free: [false, name !== "cable-block"] });
for (const { deg, centre, axis, radius } of CLAMPS) {
  clampAt("controller", centre, axis, radius, { move: CTRL, bundle: "harness" });
  const s = centre[2] - Z_EXIT;
  const inner = Math.hypot(centre[0], centre[1]) - radius;
  const root = polar(deg, radiusOn(DOME_PROFILE, s), s);
  boss("chamber", root, dir(deg), 0.9, inner - radiusOn(DOME_PROFILE, s), { host: DOME, ring: false, name: "chamber.clamp-stud" });
}
const OVER = BOX_Z + BOX_H + 4;
cable("controller", [plug(5), [plug(5)[0], DROP_Y, plug(5)[2]], [plug(5)[0], DROP_Y, OVER], [plug(5)[0], BOX.y - 4, OVER], polar(104, 36, 240), polar(100, 30, 228.5), ORPB_EXCITER], { move: CTRL, name: "controller.cable-orpb", shells: [false, true], free: [false, true] });
const MOV_IN = G.add3(MOV.port, G.mul3(dir(110), 9));
cable("controller", [plug(4), [plug(4)[0], DROP_Y, plug(4)[2]], [MOV_IN[0], DROP_Y, MOV_IN[2]], MOV_IN, MOV.port], { move: CTRL, name: "controller.cable-mov", shells: [false, true], free: [false, true] });
cable("controller", [[JBOX.x + JBOX.w / 2, JBOX.y + JBOX.d / 2, Z_EXIT + 256], [JBOX.x + JBOX.w / 2, JBOX.y + JBOX.d / 2, Z_EXIT + 265], [BOX.x + 3, BOX.y + BOX.d / 2, Z_EXIT + 265], [BOX.x + 3, BOX.y + BOX.d / 2, BOX_Z + BOX_H]], { move: CTRL, name: "controller.cable-jbox", bend: 3 });
cable("stand", [[JBOX.x + 3, JBOX.y + 3, Z_EXIT + 256], [JBOX.x + 3, JBOX.y + 3, BEAM_Z]], { name: "stand.cable-press", shells: [true, true], free: [true, false] });


const FEED_TOP = BEAM_Z - 6;
pipe("stand", [ex(FTP, 265), ex(FTP, 290), [FTP_AT[0], FTP_AT[1], FEED_TOP]], 7.2, { bend: 2, bellows: [0.12, 0.32, 7], bands: [0.62], maxLength: 14, name: "stand.feed", free: [true, false] });
const BRACKET = { x: FTP_AT[0] - 13, y: FTP_AT[1] - 13, w: 26, d: 26, r: 2 };
boxAt("stand", BRACKET, FEED_TOP, 6, {}, { bias: 2, name: "stand.feed-bracket", extra: Dots(k.corners(BRACKET, 3).map(([x, y]) => at([x, y, FEED_TOP + 6])), { size: 0.45 }) });
for (const [deg, reach] of [[135, 50], [-45, 46]]) {
  const quick = polar(deg, reach, TOP + 21);
  boxAt("stand", { x: quick[0] - 4, y: quick[1] - 4, w: 8, d: 8, r: 1 }, quick[2], BEAM_Z - quick[2], {}, { bias: 1, name: "stand.quick-disconnect" });
}

const CLEVIS_BALL = 4.6;
for (const [deg, foot] of LUGS) {
  const lug = lugBall(foot);
  const top = [Math.cos(deg * DEG) * 92, Math.sin(deg * DEG) * 92, BEAM_Z];
  const id = nameFor("stand.actuator");
  boxAt("stand", { x: top[0] - 5, y: top[1] - 5, w: 10, d: 10, r: 1 }, BEAM_Z - 3, 3, {}, { name: `${id}.mount`, bevel: 0.4 });
  const pivot = G.add3(top, [0, 0, -3 - CLEVIS_BALL]);
  ballAt("stand", pivot, CLEVIS_BALL, { tone: "hi", name: `${id}.pivot`, owner: id });
  const axis = G.unit3(G.sub3(lug, pivot));
  const span = G.len3(G.sub3(lug, pivot));
  const F = G.frameAlong(pivot, axis);
  const clevisFrom = Math.sqrt(CLEVIS_BALL ** 2 - 3 ** 2);
  put("stand", G.add3(pivot, G.mul3(axis, 5)), S(G.disc(clevisFrom, 9, 3, F, P), { tone: "mid" }), { name: `${id}.clevis`, shapes: [record(A.disc(F, clevisFrom, 9, 3, { name: `${id}.clevis`, owner: id }))] });
  const side = G.unit3(G.cross3(axis, [0, 0, 1]));
  const SF = { o: pivot, a: axis, u: side, v: G.cross3(axis, side) };
  const head = [[9, 0], [9, 6.2], [11, 6.6], [13, 6.6], [13, 0]];
  const barrel = [[15, 0], [15, 6.6], [35, 6.6], [37, 6.2], [39, 4], [39, 0]];
  put("stand", G.add3(pivot, G.mul3(axis, 11)), S(G.lathe(head, F, P, { steps: 40 }), { tone: "hi" }), { name: `${id}.head`, shapes: [record(A.body(F, head, { name: `${id}.head`, owner: id }))] });
  put("stand", G.add3(pivot, G.mul3(axis, 14)), S(G.prismOf(SF, G.stadium(7.4, 5, 10.9), 13, 15, P), { tone: "mid" }) + Dots([G.pointOf(SF, 15, 0, 0), G.add3(G.pointOf(SF, 15, 0, 0), G.mul3(side, 10.9))].map((p) => at(p)), { size: 0.42 }), { name: `${id}.gearbox`, shapes: [record(A.disc(F, 13, 15, 7.4, { name: `${id}.gearbox`, owner: id })), record(A.disc(G.frameAlong(G.add3(pivot, G.mul3(side, 10.9)), axis), 13, 15, 5, { name: `${id}.gearbox`, owner: id })), record(A.orientedBox(G.add3(G.add3(pivot, G.mul3(axis, 14)), G.mul3(side, 5.45)), [side, SF.v, axis], [5.45, 5, 1], { name: `${id}.gearbox`, owner: id }))] });
  put("stand", G.add3(pivot, G.mul3(axis, 25)), S(G.lathe(barrel, F, P, { steps: 40 }), { tone: "hi", inner: Ln([21, 27, 32].map((s) => G.arcOf(F, s, 6.6, P)).join(""), { tone: "faint" }) }), { name: `${id}.barrel`, shapes: [record(A.body(F, barrel, { name: `${id}.barrel`, owner: id }))] });
  const MF = G.frameAlong(G.add3(pivot, G.mul3(side, 10.9)), axis);
  put("stand", G.pointOf(MF, 22, 0, 0), S(G.disc(15, 29, 4.2, MF, P), { tone: "mid" }) + Faded(G.ribsOf(MF, 15, 29, 4.2, 14, P, { fade: [0.1, 0.55] }), { tone: "lo" }), { bias: -0.5, name: `${id}.motor`, shapes: [record(A.disc(MF, 15, 29, 4.2, { name: `${id}.motor`, owner: id }))] });
  const rodEnd = span - Math.sqrt(LUG_BALL ** 2 - 2.1 ** 2);
  put("stand", G.add3(pivot, G.mul3(axis, (39 + rodEnd) / 2)), S(G.disc(39, rodEnd, 2.1, F, P), { tone: "hi" }), { name: `${id}.rod`, shapes: [record(A.disc(F, 39, rodEnd, 2.1, { name: `${id}.rod`, owner: id }))] });
  ballAt("stand", lug, LUG_BALL, { name: `${id}.eye`, owner: id });
}

const BEAM_HALF = 14;
const along = G.unit3([1, -1, 0]);
const across = G.unit3([1, 1, 0]);
const beamEnd = 196;
const beamRing = [
  G.add3(G.mul3(along, -beamEnd), G.mul3(across, -BEAM_HALF)),
  G.add3(G.mul3(along, beamEnd), G.mul3(across, -BEAM_HALF)),
  G.add3(G.mul3(along, beamEnd), G.mul3(across, BEAM_HALF)),
  G.add3(G.mul3(along, -beamEnd), G.mul3(across, BEAM_HALF)),
].map(([x, y]) => [x, y]);
const beamTop = BEAM_Z + BEAM_H;
const beamLine = (offset, z) => k.onTop([G.add3(G.mul3(along, -beamEnd), G.mul3(across, offset)), G.add3(G.mul3(along, beamEnd), G.mul3(across, offset))].map(([x, y]) => [x, y]), z, P);
const beamBolts = [];
for (let t = -176; t <= 176; t += 32) for (const side of [-1, 1]) beamBolts.push(at([...G.add3(G.mul3(along, t), G.mul3(across, side * 8.5)).slice(0, 2), beamTop]));
const beamFace = [];
for (let t = -168; t <= 168; t += 56) {
  const p = G.add3(G.mul3(along, t), G.mul3(across, BEAM_HALF));
  beamFace.push(k.segment([p[0], p[1], BEAM_Z], [p[0], p[1], beamTop], P));
}
put("stand", [0, 0, BEAM_Z - 13], S(k.cylinder(0, 0, 12.6, Z_EXIT + TOP, BEAM_Z - 8 - Z_EXIT - TOP, P, 40), { tone: "mid" }) + Ln(k.knurl(0, 0, 12.6, Z_EXIT + TOP + 4, Z_EXIT + TOP + 10, 40, P), { tone: "lo" }) + Ln(k.sideArc(0, 0, 12.6, Z_EXIT + TOP + 11.5, P, 24), { tone: "faint" }), { bias: 50, name: "stand.loadcell", shapes: [record(A.disc(DECKF, Z_EXIT + TOP, BEAM_Z - 8, 12.6, { name: "stand.loadcell" }))] });
put("stand", [0, 0, BEAM_Z - 4], S(k.cylinder(0, 0, 34, BEAM_Z - 8, 8, P, 72, 0.8), { tone: "mid" }) + Dots(G.dotsOf(DECKF, BEAM_Z, 29.5, 16, P, { fade: FADE }), { size: 0.4 }), { bias: 60, name: "stand.crosshead", shapes: [record(A.disc(DECKF, BEAM_Z - 8, BEAM_Z, 34, { name: "stand.crosshead" }))] });
const GLAND_DEG = AZIMUTH - 90;
const gland = [Math.cos(GLAND_DEG * DEG) * 12.6, Math.sin(GLAND_DEG * DEG) * 12.6, Z_EXIT + TOP + 7];
put("stand", gland, S(G.disc(0, 4, 2, G.frameAlong(gland, dir(GLAND_DEG)), P), { tone: "mid" }), { bias: 61, name: "stand.gland", shapes: [record(A.disc(G.frameAlong(gland, dir(GLAND_DEG)), 0, 4, 2, { name: "stand.gland" }))] });
tubeItems("stand", G.fillet([G.add3(gland, G.mul3(dir(GLAND_DEG), 4)), G.add3(gland, G.mul3(dir(GLAND_DEG), 11)), [...G.add3(gland, G.mul3(dir(GLAND_DEG), 11)).slice(0, 2), BEAM_Z - 8]], 4), 0.62, { name: "stand.loadcell-cable", tone: "mid", bias: 61, maxLength: 60, spacing: 1 });
put("stand", [0, 0, BEAM_Z + BEAM_H / 2], S(k.extrude(beamRing, BEAM_Z, BEAM_H, P, { bevel: 0.9 }), { tone: "mid" }) + Ln(beamLine(-BEAM_HALF + 5, beamTop) + beamLine(BEAM_HALF - 5, beamTop), { tone: "lo" }) + Ln(beamFace.join(""), { tone: "faint" }) + Dots(beamBolts, { size: 0.45 }), { bias: 200, name: "stand.beam", shapes: [record(A.orientedBox([0, 0, BEAM_Z + BEAM_H / 2], [along, across, [0, 0, 1]], [beamEnd, BEAM_HALF, BEAM_H / 2], { name: "stand.beam" }))] });


PIECES.plumbing.say = `plumbing · ${senses.length} sense lines into a block · ${LINES.length - senses.length} feed, purge and igniter lines`;
PIECES.controller.say = `engine controller · ${CABLES.filter((entry) => entry.piece === "controller").length} cables`;

const CROWD_OK = [["plumbing.lox-to-frpb", "controller.cable-block"], ["plumbing.lox-to-frpb", "controller.harness-frpb"]];
A.settle(R, P);
items.sort((a, b) => a.key - b.key);
const groups = items.map((item, index) => {
  const [mx, my, mz] = item.move;
  return `<g class="it" data-piece="${item.piece}"${item.ride !== item.piece ? ` data-ride="${item.ride}"` : ""} data-k="${item.key.toFixed(2)}" data-m="${mx.toFixed(1)},${my.toFixed(1)},${mz.toFixed(1)}" data-i="${index}">${item.svg}</g>`;
});

const LABEL_TEXT =
  "SpaceX Raptor 2 hanging from a vertical test stand: a deck on feet with a flame cone and deluge ring, two columns, and a crosshead with a load cell and two gimbal actuators. Below the gimbal sit the oxygen turbopump, the oxygen-rich preburner, the injector and main chamber, and the bell nozzle with its cooling manifold; the methane turbopump and fuel-rich preburner hang on the right, joined by the hot-gas manifold, a downcomer, ducts, sense lines and the engine controller's harness.";

const backSvg = k.figureSvg({ width: W, height: H, label: LABEL_TEXT, body: back, className: "rp-back" });
const frontSvg = `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg rp-front" viewBox="0 0 ${W} ${H}" aria-hidden="true"><g class="rp-engine">${groups.join("")}</g></svg>`;

const unit = (vector) => {
  const o = at([0, 0, 0]);
  const p = at(vector);
  return [p[0] - o[0], p[1] - o[1]];
};
const DATA = {
  W,
  H,
  V,
  EX: unit([1, 0, 0]),
  EY: unit([0, 1, 0]),
  EZ: unit([0, 0, 1]),
  camera: glCamera(P),
  exit: at([0, 0, Z_EXIT]),
  zExit: Z_EXIT,
  exitR: 64.6,
  coneTip: CONE_TIP,
  plumeLength: Z_EXIT - CONE_TIP,
  chamberBar: CHAMBER_BAR,
  vacuumTf: SEA_LEVEL_TF + BACK_TF,
  backTf: BACK_TF,
  columns: COLUMN_AT,
  columnHalf: COLUMN / 2 + 0.4,
  columnTop: BEAM_Z + BEAM_H,
  pieces: Object.fromEntries(Object.entries(PIECES).map(([id, piece]) => [id, { name: piece.name, say: piece.say }])),
  stagger: STAGGER,
  spring: SPRING,
  order: ["gimbal", "otp", "orpb", "lox", "controller", "chamber", "hotgas", "ftp", "downcomer", "plumbing", "nozzle"],
};

const PARTS = DATA.order.map((id, index) => `<button type="button" class="rp-part" data-part="${id}"><span class="rp-num">${String(index + 1).padStart(2, "0")}</span>${PIECES[id].name}</button>`).join("");
const corners = `<div class="iso-plate-corners" aria-hidden="true"><span class="iso-plate-corner" data-corner="fig">Fig 1</span><span class="iso-plate-corner" data-corner="title">Raptor 2 · test stand</span><span class="iso-plate-corner" data-corner="readout" data-readout>1,630 kg · cold</span></div>`;
const controls = `<div class="rp-controls"><button type="button" class="rp-fire" data-fire><span class="rp-lamp" aria-hidden="true"></span><span data-fire-label>Ignite</span></button><span class="rp-hint">Hover the engine to take it apart</span><span class="rp-live" aria-live="polite" data-live></span></div>`;
const glowSvg = `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg rp-glow" viewBox="0 0 ${W} ${H}" aria-hidden="true"><g class="rp-glow-g"><path class="rp-rim rp-rim-soft" d="${LIP}"/><path class="rp-rim" d="${LIP}"/></g></svg>`;
const stage = `<div class="rp-stage" tabindex="0" role="slider" aria-label="Raptor 2 on a test stand. Hover or use the arrow keys to take the engine apart part by part; press I or the Ignite button to fire it, Escape to put it back together." aria-valuemin="0" aria-valuemax="${DATA.order.length}" aria-valuenow="0" aria-valuetext="Assembled, cold">${backSvg}<canvas class="rp-plume" aria-hidden="true"></canvas>${frontSvg}${glowSvg}<canvas class="rp-veil" aria-hidden="true"></canvas><div class="rp-flash" aria-hidden="true"></div></div>`;
const caption =
  "Raptor 2 is SpaceX's full-flow staged-combustion engine: liquid oxygen and methane at a mixture ratio of 3.6, 300 bar in the main chamber, 230 tf at sea level, 1,630 kg dry. Every drop of oxygen passes through an oxygen-rich preburner that spins the oxygen pump, and every drop of methane through a fuel-rich preburner that spins the methane pump; the two hot gases meet in the main chamber, which has no igniter of its own. The load cell above the gimbal reads the thrust while it burns.";
const body = `<figure class="iso-figure rp"><div class="iso-plate">${corners}${stage}${controls}</div><figcaption class="iso-legend"><span class="rp-parts">${PARTS}</span><span class="iso-caption">${caption}</span></figcaption></figure>`;

const CSS = readFileSync(join(HERE, "raptor.css"), "utf8");
const LIVE = readFileSync(join(HERE, "live.js"), "utf8").replace("__DATA__", JSON.stringify(DATA));
const page = k.pageHtml({ title: "Raptor 2", theme: THEME, body: `<style>${CSS}</style>${body}`, script: glScript() + LIVE, width: 980 });
const name = THEME === "light" ? "raptor-engine-light.html" : "raptor-engine.html";
writeFileSync(join(HERE, name), page);
console.log("wrote", name, `${(page.length / 1024).toFixed(0)} KB`, `${items.length} items`);

A.auditOrExit(R, P, { crowd: CROWD_OK });
```

## examples/raptor-engine/live.js

```js
const D = __DATA__;
const root = document.querySelector(".rp");
const stage = root.querySelector(".rp-stage");
const front = stage.querySelector(".rp-front");
const engine = front.querySelector(".rp-engine");
const canvas = stage.querySelector(".rp-plume");
const veilCanvas = stage.querySelector(".rp-veil");
const glowGroup = stage.querySelector(".rp-glow-g");
const flashVeil = stage.querySelector(".rp-flash");
const live = root.querySelector("[data-live]");
const readout = root.querySelector("[data-readout]");
const fireButton = root.querySelector("[data-fire]");
const fireLabel = root.querySelector("[data-fire-label]");
const partButtons = [...root.querySelectorAll(".rp-part")];
const still = matchMedia("(prefers-reduced-motion: reduce)");

const nodes = [...engine.querySelectorAll(".it")].map((el) => ({
  el,
  piece: el.dataset.piece,
  ride: el.dataset.ride || el.dataset.piece,
  key: Number(el.dataset.k),
  m: el.dataset.m.split(",").map(Number),
  index: Number(el.dataset.i),
  shown: "",
}));
for (const node of nodes) node.lift = node.m[0] * D.V[0] + node.m[1] * D.V[1] + node.m[2] * D.V[2];
let order = nodes.map((_, index) => index);

const IDS = Object.keys(D.pieces);
const STAGGER = D.stagger;
const SPRING = D.spring;
const piece = Object.fromEntries(IDS.map((id) => [id, { x: 0, v: 0, target: 0, pending: null }]));
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
const fmt = (value) => Math.round(value).toLocaleString("en-US");

const pct = (value) => `${(value * 100).toFixed(2)}%`;
stage.style.setProperty("--gx", pct(D.exit[0] / D.W));
stage.style.setProperty("--gy", pct((D.exit[1] + 6) / D.H));

let clock = 0;
let apart = false;
let selected = null;
let inside = false;
let leaveTimer = 0;
let touring = !still.matches;
let tourClock = -1;
let idleTimer = 0;
let visible = false;
let frame = 0;
let last = 0;
let jitter = [0, 0];

const VENT = 0.7;
const MAIN = 1.25;
const STEADY = 9;
const PREBURNER_PC = 0.13;
const LONGEST = D.plumeLength + 60;

const glide = () => ({ x: 0, v: 0 });
const burn = {
  on: false,
  armed: false,
  tour: false,
  t: -1,
  stopAt: Infinity,
  stopping: -1,
  pc: 0,
  pcRate: 0,
  hue: glide(),
  glowStop: null,
  lastGlow: 0,
  popped: false,
  phase: "",
  pre: glide(),
  vent: glide(),
  smoke: glide(),
  reach: glide(),
  splash: glide(),
  mainAt: -99,
  popAt: -99,
};


function say(text) {
  if (readout.textContent !== text) readout.textContent = text;
}

function describe() {
  const text = selected ? D.pieces[selected].say : burn.on ? "firing" : apart ? "apart · 11 assemblies" : "Assembled, cold";
  if (stage.getAttribute("aria-valuetext") !== text) stage.setAttribute("aria-valuetext", text);
}

function setApart(on) {
  if (apart === on) return;
  apart = on;
  const goal = on ? 1 : 0;
  let index = 0;
  for (const id of on ? STAGGER : [...STAGGER].reverse()) {
    if (!piece[id]) continue;
    if (piece[id].target === goal) piece[id].pending = null;
    else piece[id].pending = { value: goal, at: clock + (still.matches ? 0 : index++ * SPRING.stagger) };
  }
  describe();
  run();
}

function select(id) {
  if (id === "stand") id = null;
  if (selected === id) return;
  selected = id;
  for (const node of nodes) {
    if (id !== null && node.piece === id) node.el.setAttribute("data-on", "");
    else node.el.removeAttribute("data-on");
  }
  if (id) stage.setAttribute("data-focus", "");
  else stage.removeAttribute("data-focus");
  partButtons.forEach((button) => (button.dataset.part === id ? button.setAttribute("data-on", "") : button.removeAttribute("data-on")));
  stage.setAttribute("aria-valuenow", String(id ? D.order.indexOf(id) + 1 : 0));
  describe();
}

function steerSpring(state, dt, calm) {
  if (state.pending && clock >= state.pending.at) {
    state.target = state.pending.value;
    state.pending = null;
  }
  if (calm) {
    state.x = state.target;
    state.v = 0;
    return false;
  }
  for (let index = 0; index < SPRING.substeps; index++) {
    const h = dt / SPRING.substeps;
    state.v += ((state.target - state.x) * SPRING.stiffness - state.v * SPRING.damping) * h;
    state.x += state.v * h;
  }
  if (Math.abs(state.target - state.x) < 2e-4 && Math.abs(state.v) < 2e-4) {
    state.x = state.target;
    state.v = 0;
    return state.pending !== null;
  }
  return true;
}

function place() {
  for (const node of nodes) {
    const share = piece[node.ride].x;
    const tx = (D.EX[0] * node.m[0] + D.EY[0] * node.m[1] + D.EZ[0] * node.m[2]) * share;
    const ty = (D.EX[1] * node.m[0] + D.EY[1] * node.m[1] + D.EZ[1] * node.m[2]) * share;
    const text = share === 0 ? "" : `translate(${tx.toFixed(2)} ${ty.toFixed(2)})`;
    if (text !== node.shown) {
      node.shown = text;
      if (text) node.el.setAttribute("transform", text);
      else node.el.removeAttribute("transform");
    }
  }
  const next = nodes.map((_, index) => index).sort((a, b) => nodes[a].key + nodes[a].lift * piece[nodes[a].ride].x - (nodes[b].key + nodes[b].lift * piece[nodes[b].ride].x));
  if (next.some((index, at) => index !== order[at])) {
    const fragment = document.createDocumentFragment();
    for (const index of next) fragment.appendChild(nodes[index].el);
    engine.appendChild(fragment);
    order = next;
  }
  const moved = Math.abs(jitter[0]) > 0.005 || Math.abs(jitter[1]) > 0.005;
  const shift = moved ? `translate(${jitter[0].toFixed(2)} ${jitter[1].toFixed(2)})` : "";
  engine.setAttribute("transform", shift);
  glowGroup.setAttribute("transform", shift);
}

const COMMON = `
uniform vec2 uJit;
uniform float uPc;
uniform float uHue;
uniform float uSmoke;
uniform float uVent;
uniform float uShockR;
uniform float uShockA;
uniform float uLight;
const float ZE=${D.zExit.toFixed(1)};
const float R0=${D.exitR.toFixed(1)};
const float PL=${D.plumeLength.toFixed(1)};
const float RB=132.;
const vec2 COL0=vec2(${D.columns[0][0].toFixed(1)},${D.columns[0][1].toFixed(1)});
const vec2 COL1=vec2(${D.columns[1][0].toFixed(1)},${D.columns[1][1].toFixed(1)});
const float COLH=${D.columnHalf.toFixed(1)};
const float COLTOP=${D.columnTop.toFixed(1)};
float ground(float r){
  if(r>56.)return 0.;
  if(r>48.)return 4.;
  if(r>43.)return mix(4.,8.,(48.-r)/5.);
  if(r>35.)return mix(8.,13.,(43.-r)/8.);
  if(r>25.)return mix(13.,19.,(35.-r)/10.);
  if(r>14.5)return mix(19.,25.,(25.-r)/10.5);
  if(r>6.)return mix(25.,29.5,(14.5-r)/8.5);
  if(r>2.)return mix(29.5,32.,(6.-r)/4.);
  return 32.;
}
bool solid(vec3 p){return p.z<ground(length(p.xy));}
float ringField(vec3 p){return length(vec2(length(p.xy)-68.,p.z-6.4))-2.1;}
vec2 groundHit(vec3 o,vec3 V){
  float tf=-o.z/V.z;
  float best=tf;
  if(length(o.xy)<56.5){
    float ta=(36.-o.z)/V.z;
    float prev=ta;
    for(int i=1;i<=28;i++){
      float t=mix(ta,tf,float(i)/28.);
      if(solid(o+t*V)){
        float lo=t;float hi=prev;
        for(int j=0;j<7;j++){float m=(lo+hi)*.5;if(solid(o+m*V))lo=m;else hi=m;}
        best=hi;
        break;
      }
      prev=t;
    }
  }
  float t6=(6.4-o.z)/V.z;
  if(abs(length((o+t6*V).xy)-68.)<12.){
    float t=(9.-o.z)/V.z;
    for(int i=0;i<14;i++){
      float d=ringField(o+t*V);
      if(d<.02)break;
      t-=d;
    }
    if(ringField(o+t*V)<.06&&t>best)return vec2(t,1.);
  }
  return vec2(best,0.);
}
float columnNear(vec3 o,vec3 V,vec2 c){
  vec3 lo=vec3(c-COLH,0.);
  vec3 hi=vec3(c+COLH,COLTOP);
  vec3 a=(lo-o)/V;
  vec3 b=(hi-o)/V;
  vec3 mn=min(a,b);
  vec3 mx=max(a,b);
  float tIn=max(mn.x,max(mn.y,mn.z));
  float tOut=min(mx.x,min(mx.y,mx.z));
  return tOut>tIn?tOut:-1e9;
}
float columnsNear(vec3 o,vec3 V){return max(columnNear(o,V,COL0),columnNear(o,V,COL1));}
float engineR(float z){
  float s=z-ZE;
  float b=36.+29.*(1.-pow(clamp(s,0.,120.)/120.,1.6))+3.;
  return mix(b,48.,smoothstep(108.,124.,s))*smoothstep(-6.,0.,s)*(1.-smoothstep(318.,330.,s));
}
float inFront(vec3 p,vec3 V){
  vec2 h=V.xy;
  float hl=length(h);
  float tau=-dot(p.xy,h)/(hl*hl);
  float d=length(p.xy+tau*h);
  float re=engineR(p.z+tau*V.z);
  float inside=1.-smoothstep(re-3.,re+3.,d);
  float surface=tau*hl+sqrt(max(re*re-d*d,0.));
  return inside*smoothstep(0.,5.,-surface);
}
float side(float front,float veil){return mix(1.-front,front,veil);}
float beyond(float t0,float tc,float sigma){return 1./(1.+exp(clamp(2.41*(t0-tc)/sigma,-30.,30.)));}
vec3 steamOf(vec3 g0,vec3 V,float tA,float u,float z0,float pf,float T,float veil){
  if(uSmoke<.0004&&uVent<.0004)return vec3(0.);
  float sE=uCam.z;
  float Hs=70.+60.*uSmoke;
  vec3 start=g0+tA*V;
  float steam=0.;
  float vapour=0.;
  for(int i=0;i<6;i++){
    float w=(float(i)+.5)/6.;
    vec3 p=start+(-Hs*log(w)/sE)*V;
    float r=length(p.xy);
    float lay=side(inFront(p,V),veil);
    float box=1.-smoothstep(120.,178.,max(abs(p.x),abs(p.y)));
    steam+=box*(.4+.6*exp(-pow((r-90.)/80.,2.)))*(1.-smoothstep(170.,330.,p.z))*lay;
    vapour+=exp(-pow(r/96.,2.))*(1.-smoothstep(40.,140.,p.z))*lay;
  }
  float layer=exp(-max(start.z,0.)/Hs)/6.;
  float b1=fbm2(vec2(u*.018+T*.03,z0*.016-T*.26+7.));
  float b2=fbm2(vec2(u*.045-T*.05,z0*.04-T*.5+2.));
  vec3 lit=mix(vec3(.3,.3,.33),vec3(.72,.4,.2),pf*exp(-max(z0,0.)/150.));
  return lit*(b1*.75+b2*.4)*smoothstep(.05,.6,b1)*steam*layer*uSmoke*1.05+vec3(.42,.47,.54)*(b2*.6+.4)*vapour*layer*uVent*.5;
}
float shellOf(float dist,float tc,float tAir,float rs,float w,vec3 e0,vec3 V,float veil){
  if(rs<.5)return 0.;
  float m=sqrt(max(rs*rs-dist*dist,0.));
  float edge=exp(-pow(max(dist-rs,0.)/w,2.));
  float limb=rs*w/sqrt(m*m+2.*rs*w)/sqrt(rs*w*.5);
  limb*=limb;
  float soft=w*2.+2.;
  float far=tc-m;
  float near=tc+m;
  float aFar=smoothstep(tAir-soft*.5,tAir+soft,far)*side(inFront(e0+far*V,V),veil);
  float aNear=smoothstep(tAir-soft*.5,tAir+soft,near)*side(inFront(e0+near*V,V),veil);
  return edge*limb*(aFar+aNear)*.5;
}
vec3 shockOf(float dist,float tc,float tAir,vec3 e0,vec3 V,float veil){
  if(uShockA<.0004)return vec3(0.);
  float rs=uShockR;
  float sw=1.8+max(rs-26.,0.)*.01;
  float lead=shellOf(dist,tc,tAir,rs,sw,e0,V,veil);
  float trail=shellOf(dist,tc,tAir,max(rs-sw*2.6,0.),sw*2.2,e0,V,veil)*.3;
  return vec3(.84,.88,1.)*(lead+trail)*.42*uShockA*(1.-smoothstep(170.,270.,rs));
}
vec4 inkOf(vec3 col){
  col=tonemap(col,1.15);
  float m=max(col.r,max(col.g,col.b));
  float n=(hash12(gl_FragCoord.xy+fract(uTime*.61)*91.)+hash12(gl_FragCoord.yx*1.37+fract(uTime*.37)*57.)-1.)/255.;
  col=max(col+n*smoothstep(0.,1.5/255.,m),0.);
  m=max(col.r,max(col.g,col.b));
  vec3 ink=col/max(m,1e-5)*vec3(.62,.34,.3);
  vec4 light=vec4(ink*m*.92,m*.92);
  return mix(vec4(col,m),light,uLight);
}
struct Scene{vec3 V;vec3 g0;vec3 e0;vec3 hit;float tLow;float ring;float tCol;float tAir;float tAirE;float tLowE;float u;float z0;float s0;float dist;float tc;};
Scene sceneOf(vec2 vb){
  Scene S;
  S.V=towardViewer();
  float sE=uCam.z;
  S.g0=rayOf(vb);
  vec2 gh=groundHit(S.g0,S.V);
  S.tLow=gh.x;
  S.ring=gh.y;
  S.tCol=columnsNear(S.g0,S.V);
  S.tAir=max(S.tLow,S.tCol);
  S.hit=S.g0+S.tLow*S.V;
  S.e0=rayOf(vb-uJit);
  S.u=(vb.x-uJit.x-uOrigin.x)/uK;
  S.z0=S.e0.z;
  S.s0=ZE-S.z0;
  S.tLowE=S.tLow+(S.g0.z-S.e0.z)/sE;
  S.tAirE=S.tAir+(S.g0.z-S.e0.z)/sE;
  vec3 C=vec3(0.,0.,ZE-6.);
  S.tc=dot(C-S.e0,S.V);
  S.dist=length(S.e0+S.tc*S.V-C);
  return S;
}
`;

const PLUME = `${COMMON}
uniform float uGlow;
uniform float uPre;
uniform float uReach;
uniform float uSplash;
uniform float uFlash;
uniform float uRingR;
uniform float uRingA;
uniform float uPop;
uniform float uPopR;
float onDeck(vec2 p){return 1.-smoothstep(134.,150.,max(abs(p.x),abs(p.y)));}
float radiusAt(float s,float pf,float warm){
  float spread=.045+.12*(1.-pf);
  return R0*(1.-.07*pf*smoothstep(0.,40.,s)-.42*warm*smoothstep(0.,80.,s))+spread*max(s,0.);
}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  Scene S=sceneOf(vb);
  vec3 V=S.V;
  float sE=uCam.z;
  float cE=uCam.w;
  float tanE=sE/cE;
  float T=uTime;
  float pc=clamp(uPc,0.,1.);
  float pf=pc;
  float warm=1.-clamp(uHue,0.,1.);
  float glow=uGlow;
  float dith=hash12(gl_FragCoord.xy);
  vec3 col=vec3(0.);
  vec3 g0=S.g0;
  vec3 e0=S.e0;
  float u=S.u;
  float z0=S.z0;
  float s0=S.s0;
  vec3 hit=S.hit;
  float rho=length(hit.xy);
  vec2 dirH=hit.xy/max(rho,1e-3);

  float tongue=fbm2(vec2(u*.07,s0*.06-T*8.));
  float reach=uReach*(1.+(tongue-.5)*.55*warm);
  float rag=1.+1.6*warm;
  float lam=29.+7.*pf;
  float v0=19.+6.*pf;
  float cellOn=smoothstep(.4,.95,pf);

  if((glow>.0004||uVent>.0004)&&abs(u)<RB){
    float e=fbm2(vec2(u*.06+7.,s0*.03-T*4.6));
    float e2=fbm2(vec2(u*.16-3.,s0*.08-T*7.3));
    float streak=.86+.3*fbm2(vec2(u*.22,s0*.012-T*9.));
    float flick=(.94+.06*sin(T*97.+s0*.23))*(1.+uPre*warm*(tongue-.45)*.9);
    float mist=fbm2(vec2(u*.035+.4*T,s0*.022-T*.7));
    float halfChord=sqrt(max(RB*RB-u*u,0.))/cE;
    float ta=max(S.tAirE,-halfChord);
    float tb=min((ZE+2.-z0)/sE,halfChord);
    float dt=max(tb-ta,0.)/28.;
    vec3 sum=vec3(0.);
    vec3 cold=vec3(0.);
    for(int i=0;i<28;i++){
      float t=ta+(float(i)+dith)*dt;
      vec3 p=e0+t*V;
      float s=ZE-p.z;
      float r=length(p.xy);
      float keep=(1.-smoothstep(RB*.8,RB,r))*smoothstep(-.5,4.,s);
      float Rv=radiusAt(s,pf,warm);
      float Re=Rv*(1.+(e-.5)*.42*rag*smoothstep(4.,120.,s)+(e2-.5)*.1*rag*smoothstep(0.,40.,s));
      float rn=r/Re;
      float fade=(1.-smoothstep(reach-46.,reach,s))*keep;
      vec3 violet=mix(vec3(.86,.44,1.),vec3(1.,.5,.62),smoothstep(0.,150.,s)*.7);
      vec3 ember=mix(vec3(1.,.36,.07),vec3(1.,.7,.32),tongue);
      vec3 bodyCol=mix(violet,ember,warm);
      float fill=smoothstep(1.06,.72,rn);
      float wBody=dt*cE/(2.*Re);
      sum+=bodyCol*fill*wBody*(.26+.3*exp(-max(s,0.)/110.))*streak*flick*fade*2.;
      sum+=bodyCol*exp(-rn*rn*2.)*(dt*cE/(Re*1.25))*.22*fade;
      float rc=Rv*.34;
      sum+=mix(vec3(1.,.74,.9),vec3(1.,.8,.45),warm)*exp(-(r*r)/(rc*rc))*(dt*cE/(rc*1.772))*.34*(1.-.5*smoothstep(PL-30.,PL+10.,s))*fade;
      float rb=Rv*.62;
      sum+=mix(vec3(1.,.9,.76),vec3(1.,.78,.46),warm)*exp(-(r*r)/(rb*rb))*exp(-max(s,0.)/24.)*(dt*cE/(rb*1.772))*1.6*keep;
      float sw=Re*.24;
      float shell=exp(-pow((r-Re*.9)/sw,2.))*smoothstep(8.,150.,s)*(dt*cE/(sw*3.545));
      sum+=vec3(1.,.42,.12)*shell*.8*(.35+1.1*e)*fade;
      sum+=vec3(1.,.8,.52)*smoothstep(PL-30.,PL+4.,s)*fill*wBody*.8*fade;
      for(int c=0;c<4;c++){
        float fc=float(c);
        float vc=v0+fc*lam+sin(T*47.+fc*2.3)*.6;
        float Rc=R0*(1.-.07*pf)+(.045+.12*(1.-pf))*vc;
        float amp=exp(-fc*.28)*cellOn*(1.-smoothstep(reach-70.,reach-10.,vc))*(1.-smoothstep(PL-24.,PL-6.,vc))*keep;
        float rm=Rc*.33;
        float ph=(s-vc)/(lam*.62);
        float taper=rm*(1.06-clamp(ph,0.,1.)*.8)+.6;
        float bead=exp(-pow(r/taper,2.)*2.)*exp(-max(ph,0.)*2.2)*smoothstep(-.16,.02,ph)*(1.-smoothstep(.7,1.,ph));
        sum+=vec3(1.,.82,.46)*bead*(dt*cE/(taper*1.25))*2.1*amp;
        float d=r/(Rc*.9)+abs(s-vc+lam*.08)/(lam*.5);
        sum+=vec3(1.,.5,.64)*pow(clamp(1.-d,0.,1.),2.2)*(dt*cE/(Rc*.9*.62))*1.05*amp;
      }
      float sigma=R0*(.85+max(s,0.)*.012);
      cold+=vec3(.5,.56,.64)*exp(-(r*r)/(sigma*sigma))*(dt*cE/(sigma*1.772))*smoothstep(0.,16.,s)*(1.-smoothstep(50.,170.,s))*keep;
    }
    col+=sum*glow;
    col+=cold*mist*uVent*.62;
  }

  if(glow>.0004){
    float sh=radiusAt(clamp(s0,0.,PL),pf,warm)*2.2;
    float span=2.6*sh/cE;
    float h0=max(S.tAirE,-span);
    float dh=max(span-h0,0.)/8.;
    float halo=0.;
    for(int i=0;i<8;i++){
      float t=h0+(float(i)+dith)*dh;
      float z=z0+t*sE;
      float rr=u*u+t*t*cE*cE;
      halo+=exp(-rr/(sh*sh))*exp(-max(ZE-z,0.)/240.)*smoothstep(0.,30.,z)*smoothstep(ZE+25.,ZE-15.,z);
    }
    col+=mix(vec3(.9,.42,.62),vec3(1.,.45,.16),warm)*halo*(dh*cE/(sh*1.772))*.16*glow;
  }
  for(int c=0;c<4;c++){
    float fc=float(c);
    float vc=v0+fc*lam+sin(T*47.+fc*2.3)*.6;
    float Rc=R0*(1.-.07*pf)+(.045+.12*(1.-pf))*vc;
    float amp=exp(-fc*.28)*cellOn*(1.-smoothstep(reach-70.,reach-10.,vc))*(1.-smoothstep(PL-24.,PL-6.,vc));
    float rm=Rc*.33;
    float q=length(vec2(u/rm,(s0-vc)/(rm*tanE+1.2)));
    col+=vec3(1.,.98,.9)*exp(-q*q*2.4)*2.6*amp*glow;
  }

  float dist=S.dist;
  col+=vec3(1.,.84,.64)*uFlash*(exp(-pow(dist/120.,2.))*.36*beyond(S.tAirE,S.tc,120.)+exp(-dist/30.)*1.5*beyond(S.tAirE,S.tc,30.));
  col+=shockOf(dist,S.tc,S.tAirE,e0,V,0.);

  float floorSeen=S.tCol>S.tLow?0.:1.;
  float deck=onDeck(hit.xy)*floorSeen;
  vec3 tube=hit-vec3(dirH*68.,6.4);
  float tubeLit=max(dot(normalize(tube),normalize(vec3(-dirH*68.,26.))),0.);
  float facing=mix(1.,.15+1.3*tubeLit,S.ring);
  float splash=uSplash*pf;
  if(splash>.0004||uRingA>.0004){
    float flow=fbm3(vec3(dirH*2.4,rho*.035-T*3.8));
    float flow2=noise3(vec3(dirH*5.1+9.,rho*.07-T*6.1));
    float reachS=56.+80.*splash;
    float sheet=exp(-pow(rho/reachS,1.5))*deck*facing;
    vec3 hot=mix(vec3(1.,.38,.1),vec3(1.,.82,.55),exp(-rho/26.));
    col+=hot*sheet*(.26+.56*flow+.22*flow2)*splash*.62;
    col+=vec3(1.,.88,.7)*exp(-pow(rho/40.,2.))*splash*.9*floorSeen;
    float dust=noise3(vec3(dirH*7.,rho*.05));
    float gw=4.+uRingR*.034;
    col+=vec3(1.,.8,.6)*exp(-pow((rho-uRingR)/gw,2.))*(.45+dust)*deck*facing*uRingA*.5;
    if(splash>.0004){
      float tS=(70.-g0.z)/sE;
      float dS=max(tS-S.tAir,0.)/10.;
      float curtain=0.;
      for(int i=0;i<10;i++){
        vec3 p=g0+(S.tAir+(float(i)+dith)*dS)*V;
        float r=length(p.xy);
        float h=max(p.z-ground(r),0.);
        curtain+=exp(-pow((r-reachS*.8)/(reachS*.34),2.))*exp(-h/26.)*onDeck(p.xy)*dS*sE/26.;
      }
      float lick=fbm2(vec2(u*.05+T*.3,z0*.06-T*1.4));
      col+=vec3(1.,.46,.16)*curtain*lick*.55*splash;
    }
  }

  col+=steamOf(g0,V,S.tAir,u,z0,pf,T,0.);

  if(uPop>.0004){
    vec3 Cp=vec3(0.,0.,ZE-uPopR*.55);
    float tp=dot(Cp-e0,V);
    float pd=length(e0+tp*V-Cp);
    float pn=fbm2(vec2(u*.045,s0*.045-T*2.4));
    col+=vec3(1.,.46,.15)*exp(-pow(pd/uPopR,2.))*(.55+.7*pn)*smoothstep(ZE+24.,ZE-12.,z0)*beyond(S.tAirE,tp,uPopR)*uPop*.95;
  }

  gl_FragColor=inkOf(col);
}`;

const VEIL = `${COMMON}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  Scene S=sceneOf(vb);
  float pf=clamp(uPc,0.,1.);
  vec3 col=steamOf(S.g0,S.V,S.tAir,S.u,S.z0,pf,uTime,1.);
  col+=shockOf(S.dist,S.tc,S.tAirE,S.e0,S.V,1.);
  gl_FragColor=inkOf(col);
}`;

const SHARED = ["uJit", "uPc", "uHue", "uSmoke", "uVent", "uShockR", "uShockA", "uLight"];
const layer = glLayer({
  canvas,
  fragment: PLUME,
  view: [D.W, D.H],
  camera: D.camera,
  uniforms: [...SHARED, "uGlow", "uPre", "uReach", "uSplash", "uFlash", "uRingR", "uRingA", "uPop", "uPopR"],
});
const veil = layer
  ? glLayer({
      canvas: veilCanvas,
      fragment: VEIL,
      view: [D.W, D.H],
      camera: D.camera,
      uniforms: SHARED,
    })
  : null;
if (layer) stage.setAttribute("data-gl", "");
const LIGHT = document.body.dataset.theme === "light" || root.closest('[data-theme="light"]') ? 1 : 0;

let lastKey = "";
function paintPlume(values, active, veiled) {
  if (!layer) return;
  if (!active) {
    layer.clear();
    if (veil) veil.clear();
    lastKey = "";
    return;
  }
  const time = still.matches ? 0.8 : clock;
  const key = still.matches ? JSON.stringify(values) + veiled : "";
  if (key && key === lastKey) return;
  lastKey = key;
  layer.draw(values, time);
  if (!veil) return;
  if (veiled) veil.draw(values, time);
  else veil.clear();
}

function announce(phase) {
  if (burn.phase === phase) return;
  burn.phase = phase;
  if (live) live.textContent = phase;
}

function fireState(state) {
  if (state) fireButton.dataset.state = state;
  else delete fireButton.dataset.state;
}

function ignite(fromTour = false) {
  if (burn.on) return;
  setApart(false);
  select(null);
  burn.on = true;
  describe();
  burn.armed = true;
  burn.tour = fromTour;
  burn.t = 0;
  burn.stopping = -1;
  burn.glowStop = null;
  burn.popped = false;
  burn.stopAt = fromTour ? MAIN + 4.2 : MAIN + STEADY;
  fireState("armed");
  fireLabel.textContent = "Shut down";
  run();
}

function shutdown() {
  if (!burn.on || burn.stopping >= 0) return;
  burn.stopping = burn.t;
}

function pcTarget() {
  if (!burn.on || burn.armed || burn.stopping >= 0) return 0;
  if (burn.t >= MAIN) return 1;
  if (burn.t >= VENT) return PREBURNER_PC;
  return 0;
}

function stepBurn(dt, calm) {
  if (burn.on && burn.armed && IDS.every((id) => piece[id].x < 0.002 && !piece[id].pending)) {
    burn.armed = false;
    fireState("on");
    if (calm) burn.t = MAIN + 0.5;
  }
  const before = burn.t;
  if (burn.on && !burn.armed) burn.t += dt;
  const t = burn.t;
  if (burn.on && burn.stopping < 0 && t >= burn.stopAt) burn.stopping = t;
  const lit = burn.on && !burn.armed && burn.stopping < 0;
  if (lit && t >= MAIN && before < MAIN && !calm) burn.mainAt = clock;
  const target = pcTarget();
  if (calm) {
    burn.pc = target;
    burn.pcRate = 0;
  } else {
    const falling = burn.stopping >= 0;
    const stiff = falling ? 16 : target > PREBURNER_PC ? 20 : 64;
    const drag = falling ? 8.2 : 2 * Math.sqrt(stiff);
    for (let index = 0; index < 4; index++) {
      const h = dt / 4;
      burn.pcRate += ((target - burn.pc) * stiff - burn.pcRate * drag) * h;
      burn.pc = clamp(burn.pc + burn.pcRate * h, 0, 1);
    }
  }
  settle(burn.hue, lit && burn.pc > 0.2 ? 1 : 0, dt, lit ? 0.32 : 0.5, calm);
  const onset = smooth(0, 0.1, burn.pc);
  let glow = onset * (1.15 - 0.15 * burn.hue.x);
  if (burn.stopping >= 0 && !calm) {
    if (!burn.glowStop) burn.glowStop = { glow: burn.lastGlow ?? glow, onset: Math.max(onset, 1e-3), pc: Math.max(burn.pc, 1e-3) };
    const stop = burn.glowStop;
    glow = Math.min(stop.glow, stop.glow * Math.min(1, onset / stop.onset) * Math.sqrt(Math.min(1, burn.pc / stop.pc)));
  }
  if (!burn.on) glow = 0;
  burn.lastGlow = glow;
  settle(burn.pre, lit && t > VENT && t < MAIN + 0.1 ? 1 : 0, dt, lit && t < MAIN ? 0.06 : 0.12, calm);
  settle(burn.vent, lit && t < MAIN ? 1 : 0, dt, lit && t < MAIN ? 0.16 : 0.24, calm);
  settle(burn.reach, 72 * smooth(0, 0.12, burn.pc) + (LONGEST - 72) * smooth(0.16, 0.75, burn.pc), dt, burn.stopping >= 0 ? 0.12 : 0.07, calm);
  settle(burn.splash, smooth(0.35, 0.9, burn.pc) * smooth(D.plumeLength - 10, D.plumeLength + 40, burn.reach.x), dt, 0.16, calm);
  settle(burn.smoke, lit && t > MAIN + 0.4 ? 1 : 0, dt, lit ? 0.9 : 1.8, calm);
  if (burn.on && burn.stopping >= 0 && burn.pc < 0.2 && !burn.popped && !calm) {
    burn.popAt = clock;
    burn.popped = true;
  }
  if (burn.on && burn.stopping >= 0 && burn.pc < 0.003 && Math.abs(burn.pcRate) < 0.01 && t - burn.stopping > 0.9) {
    burn.on = false;
    burn.t = -1;
    burn.pc = 0;
    burn.pcRate = 0;
    burn.glowStop = null;
    fireState("");
    fireLabel.textContent = "Ignite";
    announce("cold");
    describe();
    if (burn.tour) tourClock = 0;
  }

  const sinceMain = clock - burn.mainAt;
  const sincePop = burn.popAt >= 0 ? clock - burn.popAt : -1;
  const flash = calm ? 0 : burst(sinceMain, 0.08, 0.13);
  const shockA = calm ? 0 : burst(sinceMain, 0.05, 0.12);
  const ringA = calm ? 0 : burst(sinceMain - 0.08, 0.08, 0.16);
  const pop = calm || sincePop < 0 ? 0 : burst(sincePop, 0.09, 0.2);
  const shake = calm ? 0 : 3.2 * burst(sinceMain, 0.05, 0.12);
  const hum = calm ? 0 : 0.32 * burn.pc * smooth(0.02, 0.12, burn.pc);
  jitter = [Math.sin(clock * 91) * hum + Math.sin(clock * 53) * shake, Math.cos(clock * 77) * hum * 0.7 + Math.cos(clock * 61) * shake];

  const values = {
    uJit: jitter,
    uPc: burn.pc,
    uHue: burn.hue.x,
    uGlow: glow,
    uLight: LIGHT,
    uPre: burn.pre.x,
    uVent: burn.vent.x,
    uSmoke: burn.smoke.x,
    uReach: burn.reach.x,
    uSplash: burn.splash.x,
    uFlash: flash,
    uShockR: 26 + 900 * Math.max(0, sinceMain),
    uShockA: shockA,
    uRingR: 600 * Math.max(0, sinceMain - 0.08),
    uRingA: ringA,
    uPop: pop,
    uPopR: 24 + 70 * (1 - Math.exp(-Math.max(0, sincePop) / 0.25)),
  };
  const active = burn.pc > 1e-3 || burn.pre.x > 1e-3 || burn.vent.x > 1e-3 || burn.smoke.x > 1e-3 || flash > 1e-3 || shockA > 1e-3 || ringA > 1e-3 || pop > 1e-3 || (sinceMain > 0 && sinceMain < 0.6);
  paintPlume(values, active, burn.smoke.x > 1e-3 || burn.vent.x > 1e-3 || shockA > 1e-3);

  const rim = clamp(burn.pc * 0.85 + burn.pre.x * 0.45 * smooth(0, 0.05, burn.pc) + flash * 0.6 + pop * 0.7, 0, 1);
  stage.style.setProperty("--rim", rim.toFixed(3));
  stage.style.setProperty("--deck-glow", clamp(burn.pc * 0.9 * burn.splash.x + flash * 0.5 + burn.pre.x * 0.12 * smooth(0, 0.05, burn.pc) + pop * 0.25, 0, 1).toFixed(3));
  flashVeil.style.opacity = (flash * 0.32).toFixed(3);
  stage.style.transform = shake > 0.01 ? `translate(${(Math.sin(clock * 47) * shake * 0.5).toFixed(2)}px, ${(Math.cos(clock * 59) * shake * 0.5).toFixed(2)}px)` : "";

  if (burn.on) {
    const bar = fmt(burn.pc * D.chamberBar);
    const thrust = fmt(Math.max(0, D.vacuumTf * burn.pc - D.backTf));
    if (burn.armed) {
      say("closing up · arming");
      announce("arming");
    } else if (burn.stopping >= 0) {
      say(burn.pc > 0.01 ? `shutdown · ${bar} bar` : "shutdown · purging");
      announce("shutdown");
    } else if (t < VENT) {
      say("chill-down · venting");
      announce("chill-down, venting");
    } else if (t < MAIN) {
      say(`spin-up · preburners lit · ${bar} bar`);
      announce("preburners lit");
    } else {
      say(`Pc ${bar} bar · ${thrust} tf`);
      announce("main stage");
    }
  }
  return burn.on || active;
}

const TOUR_STEP = 1.5;
function tourAt(time) {
  const apartFrom = 0.4;
  const listFrom = apartFrom + 0.9;
  const listTo = listFrom + D.order.length * TOUR_STEP;
  const fire = listTo + 1.4;
  if (time < apartFrom) return { apart: false, part: null, fire: false };
  if (time < listFrom) return { apart: true, part: null, fire: false };
  if (time < listTo) return { apart: true, part: D.order[Math.floor((time - listFrom) / TOUR_STEP)], fire: false };
  if (time < fire) return { apart: false, part: null, fire: false };
  return { apart: false, part: null, fire: true };
}

function quietTour() {
  touring = false;
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    if (inside || burn.on) return quietTour();
    touring = !still.matches;
    tourClock = -0.6;
    run();
  }, 3600);
}

function idleText() {
  if (selected) return D.pieces[selected].say;
  const moving = IDS.some((id) => Math.abs(piece[id].x - piece[id].target) > 0.002 || piece[id].pending);
  if (moving) return apart ? "taking apart" : "closing up";
  return apart ? "apart · 11 assemblies" : "1,630 kg · cold";
}

function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  clock += dt;
  const calm = still.matches;
  if (touring && !burn.on) {
    tourClock += dt;
    const step = tourAt(tourClock);
    setApart(step.apart);
    select(step.part);
    if (step.fire) ignite(true);
  }
  let moving = false;
  for (const id of IDS) if (steerSpring(piece[id], dt, calm)) moving = true;
  const burning = stepBurn(dt, calm);
  place();
  if (!burn.on) say(idleText());
  frame = visible && (moving || burning || touring) ? requestAnimationFrame(tick) : 0;
}

function run() {
  if (!frame && visible) {
    last = 0;
    frame = requestAnimationFrame(tick);
  }
}

new IntersectionObserver(
  (entries) => {
    visible = entries[entries.length - 1].isIntersecting;
    if (visible) run();
  },
  { rootMargin: "120px 0px" },
).observe(stage);

function pieceAt(event) {
  const hit = document.elementFromPoint(event.clientX, event.clientY);
  const item = hit && hit.closest ? hit.closest(".it") : null;
  return item ? item.dataset.piece : null;
}

stage.addEventListener("pointermove", (event) => {
  if (event.pointerType === "touch") return;
  inside = true;
  clearTimeout(leaveTimer);
  quietTour();
  if (burn.on) return;
  setApart(true);
  select(pieceAt(event));
  run();
});

stage.addEventListener("pointerleave", () => {
  inside = false;
  clearTimeout(leaveTimer);
  leaveTimer = setTimeout(() => {
    if (inside) return;
    select(null);
    setApart(false);
  }, 260);
});

stage.addEventListener("pointerdown", (event) => {
  if (event.pointerType !== "touch" || burn.on) return;
  quietTour();
  const id = pieceAt(event);
  if (!id || id === "stand") {
    select(null);
    setApart(!apart);
  } else {
    setApart(true);
    select(id);
  }
  run();
});

fireButton.addEventListener("click", () => {
  quietTour();
  if (burn.on) shutdown();
  else ignite(false);
  run();
});

partButtons.forEach((button) => {
  const show = () => {
    if (burn.on) return;
    quietTour();
    clearTimeout(leaveTimer);
    setApart(true);
    select(button.dataset.part);
    run();
  };
  const hide = () => {
    clearTimeout(leaveTimer);
    leaveTimer = setTimeout(() => {
      if (inside || partButtons.some((other) => other.matches(":hover, :focus-visible"))) return;
      select(null);
      setApart(false);
    }, 260);
  };
  button.addEventListener("pointerenter", show);
  button.addEventListener("focus", show);
  button.addEventListener("pointerleave", hide);
  button.addEventListener("blur", hide);
  button.addEventListener("click", show);
});

stage.addEventListener("keydown", (event) => {
  const index = selected ? D.order.indexOf(selected) : -1;
  let next = null;
  if (event.key === "ArrowRight" || event.key === "ArrowDown") next = Math.min(D.order.length - 1, index + 1);
  else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = Math.max(0, index - 1);
  else if (event.key === "Home") next = 0;
  else if (event.key === "End") next = D.order.length - 1;
  else if (event.key === "Escape") {
    event.preventDefault();
    quietTour();
    if (burn.on) shutdown();
    select(null);
    setApart(false);
    run();
    return;
  } else if (event.key === "i" || event.key === "I" || event.key === "Enter") {
    event.preventDefault();
    quietTour();
    if (burn.on) shutdown();
    else ignite(false);
    run();
    return;
  } else if (event.key === " ") {
    event.preventDefault();
    quietTour();
    if (!burn.on) setApart(!apart);
    if (!apart) select(null);
    run();
    return;
  }
  if (next === null || burn.on) return;
  event.preventDefault();
  quietTour();
  setApart(true);
  select(D.order[next]);
  run();
});

if (still.matches) touring = false;
const narrow = matchMedia("(hover: none), (max-width: 480px)");
const hint = root.querySelector(".rp-hint");
const wording = () => hint && (hint.textContent = narrow.matches ? "Tap to take apart" : "Hover the engine to take it apart");
wording();
narrow.addEventListener("change", wording);
window.addEventListener("resize", () => {
  lastKey = "";
  run();
});
place();
say(idleText());
```

## examples/raptor-engine/raptor-engine-light.html

```

```

## examples/raptor-engine/raptor-engine.html

```

```

## examples/raptor-engine/raptor.css

```

```

## examples/ripple-tank

```

```

## examples/ripple-tank/build.mjs

```js
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import * as G from "../../kit/lathe.mjs";
import { glCamera } from "../../kit/gl.mjs";
import { tubePieces, tubeSvg } from "../../kit/tube.mjs";
import { kitScript, glScript } from "../../scripts/inline-kit.mjs";
import * as M from "./model.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const theme = process.argv.includes("--light") ? "light" : "dark";
const DEG = Math.PI / 180;

const W = 720;
const H = 730;
const AZIMUTH = 35;
const ELEVATION = 44;

const BASE_H = 16;
const FOOT_H = 7;
const Z_PAPER = 0.3;
const IN = { x: -150, y: -120, w: 300, d: 240, r: 0 };
const OUT = { x: -159, y: -129, w: 318, d: 258, r: 1.5 };
const FRAME = OUT.x + OUT.w - IN.x - IN.w;
const Z = { rail: 193, bed: 202, floor: 206, water: 212, rim: 218, top: 223 };
const LAMP = [0, 0, 612];
const LAMP_R = 1.5;
const SHADOW = (LAMP[2] - Z_PAPER) / (LAMP[2] - Z.bed);
const PAPER_HALF = [Math.ceil((OUT.w / 2) * SHADOW + 3), Math.ceil((OUT.d / 2) * SHADOW + 3)];
const PAPER = { x: -PAPER_HALF[0], y: -PAPER_HALF[1], w: PAPER_HALF[0] * 2, d: PAPER_HALF[1] * 2, r: 1.5 };
const MARGIN = 26;
const BASE = { x: PAPER.x - 58, y: PAPER.y - MARGIN, w: PAPER.w + 58 + MARGIN, d: PAPER.d + MARGIN * 2, r: 12 };
const BEAM_SPREAD = 0.94;
const XS = -70;
const BAR = { x: XS - 4, y: -66, w: 8, d: 132, r: 1 };
const BAR_Z = Z.top + 2;
const BAR_H = 5;
const BEAM = { x: XS - 6, y: OUT.y - 4, w: 12, d: OUT.d + 8, r: 1.2 };
const BEAM_Z = Z.top + 28;
const BEAM_H = 6;
const SPRING_Y = 56;
const BALL_R = 2.6;
const BALL_Z = Z.water + BALL_R - 0.9;
const CONTACT_R = Math.sqrt(BALL_R * BALL_R - (BALL_Z - Z.water) ** 2);
const MOTOR_Z = BAR_Z + BAR_H + 1.6 + 6;
const MOTOR_X = [XS - 10, XS + 8];
const ECCENTRIC_X = XS + 14.6;
const LEG_AT = [IN.w / 2 + 4.5, IN.d / 2 + 4.5];
const LEGS = [
  [LEG_AT[0], LEG_AT[1]],
  [-LEG_AT[0], LEG_AT[1]],
  [LEG_AT[0], -LEG_AT[1]],
  [-LEG_AT[0], -LEG_AT[1]],
];
const LEG_R = 4.2;
const LEG_TOP = Z.rail - 6;
const POST = [172, BASE.y + 12];
const POST_R = 4.5;
const ARM_Z = LAMP[2] + 30;
const POST_TOP = ARM_Z + 16;
const BOX = { x: BASE.x + 8, y: 46, w: 44, d: 80, r: 4 };
const BOX_Z = 2;
const BOX_TOP = BOX_Z + 24;
const KNOB = [BOX.x + 23, BOX.y + 56];
const METER = [BOX.x + 22, BOX.y + 22];
const SWITCH = [BOX.x + 36, BOX.y + 72];
const LED = [BOX.x + 8, BOX.y + 72];
const RANGE = M.RANGE;
const START = { frequency: 16, spacing: 50 };
const BEACH = 9;
const FOAM_PASS = 0.12;

const P = k.fitProjection(
  [...k.boxCorners(BASE, -BASE_H - FOOT_H, 0), [0, 0, ARM_Z + 10], [POST[0] - 6, POST[1] - 6, POST_TOP + 3], [POST[0] + 6, POST[1] + 6, POST_TOP + 3], ...k.boxCorners(BEAM, BEAM_Z, BEAM_Z + BEAM_H)],
  W,
  H,
  { pad: 60, azimuth: AZIMUTH, elevation: ELEVATION },
);
const V = G.viewOf(P);
const at = (point) => k.iso(point, P);
const S = (paths, style) => k.solidSvg(paths, style);
const Ln = (d, style) => k.lineSvg(d, style);
const Dots = (points, style) => k.dotsSvg(points, style);
const slab = (plan, z, h, steps = 3, bevel = 0.5) => k.slabOf(plan, z, h, P, steps, bevel);
const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));
const UP = (o) => G.frameOf(o, [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const glassLines = (paths) => Ln(paths.outline + paths.crease, { tone: "faint" });

function vcoil(cx, cy, z0, z1, r, turns) {
  const count = Math.round(turns * 24);
  const back = [];
  const front = [];
  let run = [];
  let near = null;
  for (let index = 0; index <= count; index++) {
    const share = index / count;
    const angle = share * turns * Math.PI * 2;
    const point = at([cx + r * Math.cos(angle), cy + r * Math.sin(angle), z0 + (z1 - z0) * share]);
    const facing = Math.cos(angle) * V[0] + Math.sin(angle) * V[1] > 0;
    if (near === null) near = facing;
    if (facing !== near) {
      run.push(point);
      (near ? front : back).push(k.pathOf(run));
      run = [point];
      near = facing;
      continue;
    }
    run.push(point);
  }
  if (run.length > 1) (near ? front : back).push(k.pathOf(run));
  return { back: back.join(""), front: front.join("") };
}

function sphere(centre, r, style) {
  const profile = [];
  for (let index = 0; index <= 12; index++) {
    const a = -Math.PI / 2 + (index / 12) * Math.PI;
    profile.push([r * Math.sin(a), r * Math.cos(a), 1]);
  }
  return S(G.lathe(profile, UP(centre), P, { steps: 28 }), style);
}

function screwX(centre, r, length, { tone = "mid", ribs = 14 } = {}) {
  const F = G.frameAlong(centre, [1, 0, 0]);
  return (
    S(G.disc(0, length * 0.4, r * 0.45, F, P), { tone }) +
    S(G.disc(length * 0.4, length, r, F, P), { tone }) +
    Ln(G.ribsOf(F, length * 0.4 + 0.3, length - 0.3, r, ribs, P), { tone: "lo" })
  );
}

function cable(points, { r = 1.1, bend = 7, maxLength = 18 } = {}) {
  return tubePieces(G.fillet(points, bend, 8), r, P, { maxLength, spacing: 1.2 });
}

const back = [];
back.push(`<path class="iso-halo" d="${k.haloOf(BASE, -BASE_H - FOOT_H, BASE_H, P)}"/>`);
const FEET = byDepth([...k.corners(BASE, 28), [BASE.x + BASE.w / 2, BASE.y + 28], [BASE.x + BASE.w / 2, BASE.y + BASE.d - 28], [BASE.x + 28, 0], [BASE.x + BASE.w - 28, 0]]);
for (const [x, y] of FEET) {
  back.push(S(k.cylinder(x, y, 10, -BASE_H - FOOT_H, 2, P, 28), { tone: "lo" }));
  back.push(S(k.cylinder(x, y, 8, -BASE_H - FOOT_H + 2, FOOT_H - 2, P, 28), { tone: "mid" }));
}
back.push(S(k.slabOf(BASE, -BASE_H, BASE_H, P, 8, 1.6), { tone: "mid" }));
back.push(Ln(k.planOutline(k.insetPlan(BASE, 6), 0, P), { tone: "lo" }));
const BASE_SCREWS = k.corners(BASE, 14);
back.push(Ln(BASE_SCREWS.map(([x, y]) => k.ring(x, y, 2.8, 0, P, 16)).join(""), { tone: "lo" }));
back.push(Dots(BASE_SCREWS.map(([x, y]) => at([x, y, 0])), { size: 0.55 }));
const SIDE = k.sideTicks(BASE.y + 26, BASE.y + BASE.d - 26, 10, 5, BASE.x + BASE.w, -2.6, [2.6, 5.2], P, "y");
back.push(Ln(SIDE.minor, { tone: "lo" }), Ln(SIDE.major, { tone: "mid" }));
const STRIP = { x: PAPER.x + 10, y: BASE.y + BASE.d - 18, w: PAPER.w - 20, d: 8, r: 1 };
back.push(S(k.slabOf(STRIP, 0, 0.8, P, 3), { tone: "lo", crease: "none" }));
const TICKS = k.topTicks(STRIP.x + 5, STRIP.x + STRIP.w - 5, 5, 5, STRIP.y + 0.9, 0.8, [2.2, 4.4], P);
back.push(Ln(TICKS.minor, { tone: "lo" }), Ln(TICKS.major, { tone: "mid" }));
back.push(Dots([at([STRIP.x + 2.4, STRIP.y + 4, 0.8]), at([STRIP.x + STRIP.w - 2.4, STRIP.y + 4, 0.8])], { size: 0.45 }));
const LABEL = { x: BASE.x + BASE.w - 20, y: 40, w: 13, d: 92, r: 1.5 };
back.push(S(k.slabOf(LABEL, 0, 1, P, 3), { tone: "mid" }));
back.push(Ln(k.planOutline(k.insetPlan(LABEL, 1.8), 1, P), { tone: "faint" }));
back.push(k.faceTextSvg(k.topMatrix([LABEL.x + 8.4, LABEL.y + LABEL.d - 7], 1, P, "y"), "RIPPLE TANK", { size: 4.6, tone: "lo" }));
back.push(Ln([0, 1].map((row) => k.onTop([[LABEL.x + 3.6 + row * 2.6, LABEL.y + 7], [LABEL.x + 3.6 + row * 2.6, LABEL.y + (row ? 21 : 28)]], 1, P)).join(""), { tone: "lo", free: true }));
back.push(Dots([at([LABEL.x + LABEL.w / 2, LABEL.y + 2.8, 1]), at([LABEL.x + LABEL.w / 2, LABEL.y + LABEL.d - 2.8, 1])], { size: 0.45 }));
back.push(S(k.slabOf(PAPER, 0, Z_PAPER, P, 3), { tone: "lo", flat: true, crease: "none", className: "rt-sheet" }));
for (const [sx, sy] of [
  [1, 1],
  [-1, 1],
  [1, -1],
  [-1, -1],
]) {
  const cx = (sx > 0 ? PAPER.x + PAPER.w : PAPER.x) - sx * 2;
  const cy = (sy > 0 ? PAPER.y + PAPER.d : PAPER.y) - sy * 2;
  const along = [sx / Math.SQRT2, -sy / Math.SQRT2];
  const across = [sx / Math.SQRT2, sy / Math.SQRT2];
  const ring = [
    [-10, -3.6],
    [10, -3.6],
    [10, 3.6],
    [-10, 3.6],
  ].map(([a, b]) => [cx + along[0] * a + across[0] * b, cy + along[1] * a + across[1] * b]);
  back.push(S(k.extrude(ring, Z_PAPER, 0.2, P), { tone: "faint", flat: true, crease: "none", className: "rt-tape" }));
}

const PATTERN_GEO = { xs: XS, inside: [IN.x, IN.y, IN.x + IN.w, IN.y + IN.d], lamp: LAMP, zWater: Z.water, zPaper: Z_PAPER };
const optics = { magnification: M.magnificationOf(LAMP[2], Z_PAPER, Z.water), caustic: M.causticOf(LAMP[2], Z_PAPER, Z.water, Z.floor, Z.bed) };
const START_PHASE = 0.9;
const startPattern = M.patternOf(M.stateOf(START.frequency, START.spacing, optics), PATTERN_GEO, at, START_PHASE);
const LIFT = (LAMP[2] - Z_PAPER) / (LAMP[2] - Z.water);
const SHADOW_IN = { x: IN.x * LIFT, y: IN.y * LIFT, w: IN.w * LIFT, d: IN.d * LIFT, r: 0 };
back.push(
  `<g class="rt-fallback">${Ln(k.planOutline(SHADOW_IN, Z_PAPER, P), { tone: "faint" })}<path data-part="crests" class="iso-line" data-tone="faint" d="${startPattern.crests}"/><path data-part="nodal" class="iso-line" data-tone="lo" d="${startPattern.nodal}"/></g>`,
);

const mid = [];
const ARM_U = G.unit3([LAMP[0] - POST[0], LAMP[1] - POST[1], 0]);
const ARM_N = [-ARM_U[1], ARM_U[0], 0];
const ARM_LENGTH = Math.hypot(LAMP[0] - POST[0], LAMP[1] - POST[1]);
const FACING = Math.sign(-(ARM_N[0] * V[0] + ARM_N[1] * V[1])) || 1;
const onArm = (a, b, z = 0) => [POST[0] + ARM_U[0] * a + ARM_N[0] * b, POST[1] + ARM_U[1] * a + ARM_N[1] * b, z];
const armRing = (a0, a1, b0, b1) =>
  [
    [a0, b0],
    [a1, b0],
    [a1, b1],
    [a0, b1],
  ].map(([a, b]) => onArm(a, b).slice(0, 2));
const cableFoot = onArm(-16, 0, 1.2);
const LAMP_CABLE_LOW = cable(
  [
    onArm(-6, 0, ARM_Z + 2),
    onArm(-11.5, 0, ARM_Z - 1),
    onArm(-7.6, 0, ARM_Z - 12),
    onArm(-7.6, 0, 12),
    onArm(-14, 0, 5),
    cableFoot,
    [cableFoot[0] + 1, BASE.y + 4, 1.2],
    [cableFoot[0] + 1.4, BASE.y - 2.6, -1.4],
    [cableFoot[0] + 1.6, BASE.y - 3, -12],
  ],
  { r: 1.1, bend: 3, maxLength: 8 },
);
for (const piece of LAMP_CABLE_LOW) {
  if (piece.mid[1] < BASE.y - 0.2) back.splice(1, 0, tubeSvg(piece, { tone: "mid" }));
  else mid.push(tubeSvg(piece, { tone: "mid" }));
}
const postSeg = (z0, z1) => S(k.cylinder(POST[0], POST[1], POST_R, z0, z1 - z0, P, 28), { tone: "mid", crease: "none" });
mid.push(S(k.cylinder(POST[0], POST[1], 9.6, 0, 3.6, P, 40, 0.8), { tone: "mid" }));
mid.push(Dots(G.dotsOf(UP([POST[0], POST[1], 0]), 3.6, 7.6, 6, P, { all: true }), { size: 0.45 }));
mid.push(S(k.cylinder(POST[0], POST[1], 6.4, 3.6, 4.4, P, 28), { tone: "mid" }));
mid.push(postSeg(8, ARM_Z - 6));
mid.push(Ln([140, 280, 420].map((z) => k.sideArc(POST[0], POST[1], POST_R, z, P, 16)).join(""), { tone: "faint" }));

mid.push(S(k.extrude(armRing(-8, 8, -8, 8), ARM_Z - 6, 16, P, { bevel: 0.6 }), { tone: "mid" }));
mid.push(Dots([onArm(-5, -5, ARM_Z + 10), onArm(5, 5, ARM_Z + 10)].map(at), { size: 0.45 }));
{
  const F = G.frameAlong(onArm(0, 8 * -FACING, ARM_Z + 2), G.mul3(ARM_N, -FACING));
  mid.push(S(G.disc(0, 2.8, 1.4, F, P), { tone: "mid" }) + S(G.disc(2.8, 7, 3, F, P), { tone: "mid" }) + Ln(G.ribsOf(F, 3.1, 6.7, 3, 16, P), { tone: "lo" }));
}
mid.push(postSeg(ARM_Z + 10, POST_TOP));
mid.push(S(k.cylinder(POST[0], POST[1], 5.2, POST_TOP, 2.4, P, 28, 0.4), { tone: "mid" }));
mid.push(S(k.cylinder(0, 0, 2.6, LAMP[2] + 20, ARM_Z - LAMP[2] - 20, P, 20), { tone: "mid", crease: "none" }));
mid.push(S(k.cylinder(0, 0, 5, LAMP[2] + 18.4, 2.2, P, 24), { tone: "mid" }));
const SHADE = [
  [LAMP[2], 13.4],
  [LAMP[2] + 12.6, 13.4],
  [LAMP[2] + 13.6, 12.6, 1],
  [LAMP[2] + 16.4, 9.4, 1],
  [LAMP[2] + 18.4, 5],
];
mid.push(
  S(G.solidOf(SHADE, UP([0, 0, 0]), P, { bevel: 0.5 }), { tone: "hi", crease: "lo" }) +
    Ln(G.arcOf(UP([0, 0, 0]), LAMP[2] + 3.4, 13.4, P), { tone: "faint" }) +
    k.fadedSvg(G.ribsOf(UP([0, 0, 0]), LAMP[2] + 5.6, LAMP[2] + 10.6, 13.4, 36, P, { fade: [0.12, 0.5] }), { tone: "lo" }) +
    Dots(G.dotsOf(UP([0, 0, 0]), LAMP[2] + 1.4, 13.4, 24, P, { fade: [0.04, 0.42] }), { size: 0.4, tone: "mid" }),
);
mid.push(S(k.extrude(armRing(8, ARM_LENGTH + 7, -3.5, 3.5), ARM_Z, 6, P, { bevel: 0.5 }), { tone: "mid" }));
mid.push(Dots([20, ARM_LENGTH / 2, ARM_LENGTH - 4].map((a) => at(onArm(a, 0, ARM_Z + 6))), { size: 0.45 }));
const LAMP_CABLE_HIGH = cable(
  [
    onArm(ARM_LENGTH - 3.6, -2.2 * FACING, LAMP[2] + 20.6),
    onArm(ARM_LENGTH - 6, -5.6 * FACING, ARM_Z + 2),
    onArm(ARM_LENGTH - 16, -1.6 * FACING, ARM_Z + 7.1),
    onArm(22, -1.6 * FACING, ARM_Z + 7.1),
    onArm(15, -5.6 * FACING, ARM_Z + 3),
    onArm(8.3, -5.6 * FACING, ARM_Z + 3),
  ],
  { r: 1.1, bend: 4, maxLength: 24 },
);
for (const piece of LAMP_CABLE_HIGH) mid.push(tubeSvg(piece, { tone: "mid" }));
mid.push(S(G.disc(0, 1.6, 2, G.frameAlong(onArm(ARM_LENGTH - 3.6, -2.2 * FACING, LAMP[2] + 20.6), [0, 0, -1]), P), { tone: "mid" }));

const PLUG = [BOX.x + BOX.w, BOX.y + 40, BOX_Z + 9];
const LEFT_LEG = [-LEG_AT[0], LEG_AT[1]];
const RIM_Y = IN.y + IN.d + 2;
const MOTOR_CABLE = cable(
  [
    [MOTOR_X[0] - 1.6, 1.6, MOTOR_Z + 2],
    [MOTOR_X[0] - 4.4, 2, MOTOR_Z + 4],
    [XS - 7.6, 6, BEAM_Z + 2],
    [XS - 7.6, IN.y + IN.d - 4, BEAM_Z + 2],
    [XS - 7.4, IN.y + IN.d + 3.4, BEAM_Z - 4],
    [XS - 7.4, IN.y + IN.d + 3.4, Z.top + 3],
    [XS - 12, RIM_Y, Z.top + 1.1],
    [OUT.x + 6, RIM_Y, Z.top + 1.1],
    [OUT.x - 2, OUT.y + OUT.d + 2, Z.top - 3],
    [OUT.x - 2, OUT.y + OUT.d + 2, Z.rail - 8],
    [LEFT_LEG[0] - 5.4, LEFT_LEG[1] + 1.4, Z.rail - 20],
    [LEFT_LEG[0] - 5.4, LEFT_LEG[1] + 1.4, 16],
    [LEFT_LEG[0] - 14, LEFT_LEG[1] - 4, 1.2],
    [PLUG[0] + 22, PLUG[1], 1.2],
    [PLUG[0] + 8, PLUG[1], 1.2],
    [PLUG[0] + 4, PLUG[1], PLUG[2]],
    [PLUG[0], PLUG[1], PLUG[2]],
  ],
  { r: 1.1, bend: 6, maxLength: 12 },
);
const motorCableBridge = [];
const motorCableRim = [];
for (const piece of MOTOR_CABLE) {
  if (piece.mid[2] > Z.top - 1 && piece.mid[1] < IN.y + IN.d - 10) motorCableBridge.push(tubeSvg(piece, { tone: "mid" }));
  else if (piece.mid[2] > Z.top - 1) motorCableRim.push(tubeSvg(piece, { tone: "mid" }));
  else mid.push(tubeSvg(piece, { tone: "mid" }));
}
for (const z of [Z.rail - 44, 118, 44]) {
  mid.push(S(slab({ x: LEFT_LEG[0] - 5.4, y: LEFT_LEG[1] - 0.6, w: 5.4, d: 2.4, r: 0.4 }, z + 0.3, 2.4, 2, 0.2), { tone: "mid" }));
  mid.push(S(k.cylinder(LEFT_LEG[0] - 5.4, LEFT_LEG[1] + 1.4, 2, z, 3, P, 14), { tone: "mid" }));
}

const box = [];
for (const [x, y] of byDepth(k.corners(BOX, 7))) box.push(S(k.cylinder(x, y, 3, 0, BOX_Z, P, 16), { tone: "lo" }));
box.push(S(slab(BOX, BOX_Z, BOX_TOP - BOX_Z, 6, 0.8), { tone: "mid" }));
box.push(Ln(k.planOutline(k.insetPlan(BOX, 3), BOX_TOP, P, 4), { tone: "faint" }));
box.push(Dots(k.corners(BOX, 5.4).map(([x, y]) => at([x, y, BOX_TOP])), { size: 0.45 }));
const slotOf = (x, y0, y1, z, half) => k.pathOf([...Array.from({ length: 9 }, (_, i) => [y1 - half + half * Math.sin((i / 8) * Math.PI), z + half * Math.cos((i / 8) * Math.PI)]), ...Array.from({ length: 9 }, (_, i) => [y0 + half - half * Math.sin((i / 8) * Math.PI), z - half * Math.cos((i / 8) * Math.PI)])].map(([y, zz]) => at([x, y, zz])), true);
box.push(Ln([BOX_Z + 7, BOX_Z + 12, BOX_Z + 17].map((z) => slotOf(BOX.x + BOX.w, BOX.y + 8, BOX.y + 24, z, 0.9)).join(""), { tone: "faint" }));
box.push(S(G.disc(0, 2.2, 2.6, G.frameAlong([PLUG[0], PLUG[1], PLUG[2]], [1, 0, 0]), P), { tone: "mid" }));
const FA = (f) => (-135 + (270 * (f - RANGE.frequency[0])) / (RANGE.frequency[1] - RANGE.frequency[0])) * DEG;
box.push(S(k.cylinder(KNOB[0], KNOB[1], 10, BOX_TOP, 1, P, 40), { tone: "mid" }));
const KNOB_TICKS = k.radialTicks(KNOB[0], KNOB[1], 9.6, 10, 5, BOX_TOP + 1, [1.2, 2.4], P, -135 * DEG, 270 * DEG);
box.push(Ln(KNOB_TICKS.minor, { tone: "lo" }), Ln(KNOB_TICKS.major, { tone: "mid" }));
box.push(S(k.cylinder(KNOB[0], KNOB[1], 6.6, BOX_TOP + 1, 7, P, 36), { tone: "hi", crease: "lo" }));
box.push(Ln(k.knurl(KNOB[0], KNOB[1], 6.6, BOX_TOP + 1, BOX_TOP + 8, 40, P), { tone: "lo" }));
box.push(Ln(k.ring(KNOB[0], KNOB[1], 5, BOX_TOP + 8, P, 32), { tone: "faint" }));
const knobAt = (f) => {
  const a = FA(f);
  return k.lineOnTop([KNOB[0] + Math.sin(a) * 1.4, KNOB[1] - Math.cos(a) * 1.4], [KNOB[0] + Math.sin(a) * 5.6, KNOB[1] - Math.cos(a) * 5.6], BOX_TOP + 8, P);
};
box.push(`<path data-part="pointer" class="iso-line" data-tone="hi" d="${knobAt(START.frequency)}" style="stroke-width:1"/>`);
box.push(S(k.cylinder(METER[0], METER[1], 13, BOX_TOP, 3.2, P, 56, 0.6), { tone: "hi", crease: "lo" }));
box.push(Ln(k.ring(METER[0], METER[1], 11.2, BOX_TOP + 3.2, P, 56), { tone: "lo" }));
const METER_TICKS = k.radialTicks(METER[0], METER[1], 10.4, 20, 5, BOX_TOP + 3.2, [1.1, 2.4], P, -135 * DEG, 270 * DEG);
box.push(Ln(METER_TICKS.minor, { tone: "lo" }), Ln(METER_TICKS.major, { tone: "mid" }));
const needleAt = (f) => {
  const a = FA(f);
  return k.lineOnTop([METER[0] - Math.sin(a) * 2.2, METER[1] + Math.cos(a) * 2.2], [METER[0] + Math.sin(a) * 9.6, METER[1] - Math.cos(a) * 9.6], BOX_TOP + 3.4, P);
};
box.push(`<path data-part="needle" class="iso-line" data-tone="hi" d="${needleAt(START.frequency)}" style="stroke-width:1"/>`);
box.push(Ln(k.ring(METER[0], METER[1], 1.4, BOX_TOP + 3.4, P, 14), { tone: "mid" }));
box.push(S(k.cylinder(SWITCH[0], SWITCH[1], 3, BOX_TOP, 2, P, 6), { tone: "mid" }));
box.push(Ln(k.segment([SWITCH[0], SWITCH[1], BOX_TOP + 2], [SWITCH[0] + 2.6, SWITCH[1] + 1.4, BOX_TOP + 8], P), { tone: "hi", className: "rt-lever" }));
box.push(Dots([at([SWITCH[0] + 2.6, SWITCH[1] + 1.4, BOX_TOP + 8])], { size: 1, tone: "hi" }));
box.push(S(k.cylinder(LED[0], LED[1], 2.4, BOX_TOP, 0.8, P, 16), { tone: "mid" }));
box.push(sphere([LED[0], LED[1], BOX_TOP + 0.8], 1.4, { tone: "hi" }));
mid.push(box.join(""));

for (const [x, y] of byDepth(LEGS)) {
  const parts = [];
  parts.push(S(k.cylinder(x, y, 7, Z_PAPER, 5.6, P, 32), { tone: "mid" }));
  parts.push(Ln(k.knurl(x, y, 7, Z_PAPER, Z_PAPER + 5.6, 32, P), { tone: "lo" }));
  parts.push(S(k.cylinder(x, y, 2.3, 5.9, 12, P, 20), { tone: "hi", crease: "none" }));
  parts.push(Ln([7.6, 9, 10.4, 11.8, 13.2, 14.6, 16, 17.4].map((z) => k.sideArc(x, y, 2.3, z, P, 12)).join(""), { tone: "lo" }));
  parts.push(S(k.cylinder(x, y, 4.8, 17.9, 3.2, P, 6), { tone: "mid" }));
  parts.push(S(k.cylinder(x, y, LEG_R, 21.1, LEG_TOP - 21.1, P, 24), { tone: "mid", crease: "none" }));
  parts.push(Ln([30, 104].map((z) => k.sideArc(x, y, LEG_R, z, P, 14)).join(""), { tone: "faint" }));
  parts.push(S(k.cylinder(x, y, 5, 100, 8, P, 24, 0.4), { tone: "mid" }));
  parts.push(Ln(k.knurl(x, y, 5, 100.8, 107.2, 24, P), { tone: "lo" }));
  const bracket = { x: x - 5, y: y - 5, w: 10, d: 10, r: 1 };
  parts.push(S(slab(bracket, LEG_TOP, Z.rail - LEG_TOP, 2, 0.4), { tone: "mid" }));
  parts.push(Dots([at([bracket.x + bracket.w, y, LEG_TOP + 3]), at([x, bracket.y + bracket.d, LEG_TOP + 3])], { size: 0.4 }));
  mid.push(parts.join(""));
}

const railMinusX = { x: OUT.x, y: OUT.y, w: FRAME, d: OUT.d, r: 1 };
const railPlusX = { x: IN.x + IN.w, y: OUT.y, w: FRAME, d: OUT.d, r: 1 };
const railMinusY = { x: IN.x, y: OUT.y, w: IN.w, d: FRAME, r: 0.3 };
const railPlusY = { x: IN.x, y: IN.y + IN.d, w: IN.w, d: FRAME, r: 0.3 };
mid.push(S(slab(railMinusX, Z.rail, Z.bed - Z.rail, 2, 0.4), { tone: "mid" }));
mid.push(S(slab(railMinusY, Z.rail, Z.bed - Z.rail, 2, 0.4), { tone: "mid" }));
const POST_PLAN = (x, y) => ({ x, y, w: FRAME, d: FRAME, r: 0.8 });
const cornerPost = (plan) => S(slab(plan, Z.bed, Z.top - Z.bed, 2, 0.4), { tone: "mid" }) + Dots([at([plan.x + plan.w / 2, plan.y + plan.d / 2, Z.top])], { size: 0.45 });
mid.push(cornerPost(POST_PLAN(OUT.x, OUT.y)));
mid.push(cornerPost(POST_PLAN(IN.x + IN.w, OUT.y)));
mid.push(cornerPost(POST_PLAN(OUT.x, IN.y + IN.d)));
mid.push(glassLines(slab({ x: IN.x - 4, y: IN.y, w: 4, d: IN.d, r: 0 }, Z.floor, Z.rim - Z.floor, 1, 0)));
mid.push(glassLines(slab({ x: IN.x, y: IN.y - 4, w: IN.w, d: 4, r: 0 }, Z.floor, Z.rim - Z.floor, 1, 0)));
{
  const FOOT = k.insetPlan(IN, BEACH);
  const top = (x, y) => at([x, y, Z.water - 0.4]);
  const foot = (x, y) => at([x, y, Z.floor]);
  const [x0, y0, x1, y1] = [IN.x, IN.y, IN.x + IN.w, IN.y + IN.d];
  const [u0, v0, u1, v1] = [FOOT.x, FOOT.y, FOOT.x + FOOT.w, FOOT.y + FOOT.d];
  const faces = [
    [[top(x0, y0), top(x1, y0), foot(u1, v0), foot(u0, v0)], 3],
    [[top(x0, y0), top(x0, y1), foot(u0, v1), foot(u0, v0)], 1],
    [[top(x1, y0), top(x1, y1), foot(u1, v1), foot(u1, v0)], 2],
    [[top(x0, y1), top(x1, y1), foot(u1, v1), foot(u0, v1)], 0],
  ];
  const shell = faces.map(([ring, shade]) => `<path class="iso-fill" d="${k.pathOf(ring, true)}"/><path class="iso-shade" data-shade="${shade}" d="${k.pathOf(ring, true)}"/>`).join("");
  const seams = k.pathOf([foot(u0, v0), foot(u1, v0), foot(u1, v1), foot(u0, v1)], true) + [[x0, y0, u0, v0], [x1, y0, u1, v0], [x1, y1, u1, v1], [x0, y1, u0, v1]].map(([a, b, c, d]) => k.pathOf([top(a, b), foot(c, d)])).join("");
  mid.push(`<g class="iso-solid rt-beach">${shell}</g>` + Ln(seams, { tone: "faint" }));
}
mid.push(S(k.slabOf(IN, Z.water - 0.05, 0.05, P, 1), { tone: "faint", flat: true, crease: "none", className: "rt-water" }));

const front = [];
const CAP = 5.6;
const rimMinusX = { x: IN.x - 4 - (CAP - 4) / 2, y: IN.y, w: CAP, d: IN.d, r: 0.6 };
const rimPlusX = { x: IN.x + IN.w - (CAP - 4) / 2, y: IN.y, w: CAP, d: IN.d, r: 0.6 };
const rimMinusY = { x: IN.x, y: IN.y - 4 - (CAP - 4) / 2, w: IN.w, d: CAP, r: 0.6 };
const rimPlusY = { x: IN.x, y: IN.y + IN.d - (CAP - 4) / 2, w: IN.w, d: CAP, r: 0.6 };
const rimSvg = (plan) => S(slab(plan, Z.rim, Z.top - Z.rim, 2, 0.3), { tone: "mid" });
front.push(rimSvg(rimMinusX), rimSvg(rimMinusY));
front.push(...motorCableBridge);

const pillar = (y0) => {
  const plan = { x: XS - 5, y: y0, w: 10, d: 6, r: 1 };
  return S(slab(plan, Z.top, BEAM_Z - Z.top, 3, 0.5), { tone: "mid" }) + screwX([XS + 5, y0 + 3, Z.top + 9], 2.4, 5.6);
};
const spring = (y) => {
  const coil = vcoil(XS, y, BAR_Z + BAR_H + 0.6, BEAM_Z - 0.6, 2.3, 8);
  return Ln(coil.back, { tone: "lo" }) + S(k.cylinder(XS, y, 1.4, BAR_Z + BAR_H, 0.8, P, 16), { tone: "mid" }) + Ln(coil.front, { tone: "hi" });
};
front.push(pillar(rimMinusY.y - 0.2));
front.push(spring(-SPRING_Y));

const BAR_TICKS = k.topTicks(-48, 48, 2, 5, BAR.x + 0.8, BAR_Z + BAR_H, [1.2, 2.4], P, "y");
front.push(
  `<g data-part="bar">` +
    S(slab(BAR, BAR_Z, BAR_H, 2, 0.4), { tone: "hi", crease: "lo" }) +
    Ln(BAR_TICKS.minor, { tone: "lo" }) +
    Ln(BAR_TICKS.major, { tone: "mid" }) +
    Ln(k.lineOnTop([BAR.x + 0.8, 0], [BAR.x + 3.8, 0], BAR_Z + BAR_H, P), { tone: "hi" }) +
    Dots([at([XS, BAR.y + 2.4, BAR_Z + BAR_H]), at([XS, BAR.y + BAR.d - 2.4, BAR_Z + BAR_H])], { size: 0.4 }) +
    `</g>`,
);

function dipper() {
  const parts = [];
  parts.push(sphere([XS, 0, BALL_Z], BALL_R, { tone: "hi", lit: true }));
  parts.push(S(k.cylinder(XS, 0, 0.8, BALL_Z + BALL_R * 0.7, BAR_Z - 1.6 - BALL_Z - BALL_R * 0.7, P, 12), { tone: "hi", lit: true, crease: "none" }));
  const clamp = { x: XS - 5.6, y: -3, w: 11.2, d: 6, r: 0.8 };
  parts.push(S(slab(clamp, BAR_Z - 1.6, BAR_H + 3.6, 2, 0.4), { tone: "mid" }));
  parts.push(Ln(k.lineOnTop([clamp.x + 0.8, 0], [clamp.x + 4.2, 0], BAR_Z + BAR_H + 2, P), { tone: "lit" }));
  parts.push(screwX([clamp.x + clamp.w, 0, BAR_Z + 2.4], 1.9, 4.6));
  return parts.join("");
}
const restFar = -START.spacing / 2;
const restNear = START.spacing / 2;
front.push(`<g data-part="dipper-far" transform="${k.translateAlong(P, [0, restFar, 0])}">${dipper()}</g>`);

const MF = G.frameAlong([MOTOR_X[0], 0, MOTOR_Z], [1, 0, 0]);
const motorLength = MOTOR_X[1] - MOTOR_X[0];
const saddle = { x: XS - 4, y: -4.6, w: 8, d: 9.2, r: 0.8 };
front.push(
  `<g data-part="motor">` +
    S(slab(saddle, BAR_Z + BAR_H, 3, 2, 0.3), { tone: "mid" }) +
    S(G.lathe([[-1.6, 0], [-1.6, 3.2], [-0.4, 4.6, 1], [0, 5.2], [0, 0]], MF, P, { steps: 32 }), { tone: "mid" }) +
    S(G.disc(0, motorLength, 6, MF, P, { bevel: 0.4 }), { tone: "mid" }) +
    k.fadedSvg(G.ribsOf(MF, 3, motorLength - 4, 6, 28, P, { fade: [0.08, 0.45] }), { tone: "lo" }) +
    Ln(G.arcOf(MF, 2, 6, P) + G.arcOf(MF, motorLength - 3, 6, P), { tone: "faint" }) +
    S(G.lathe([[motorLength, 0], [motorLength, 4.6], [motorLength + 1.6, 4.2, 1], [motorLength + 2.2, 2.4], [motorLength + 2.2, 0]], MF, P, { steps: 32 }), { tone: "mid" }) +
    S(G.disc(motorLength + 2.2, ECCENTRIC_X - MOTOR_X[0] - 2.4, 0.9, MF, P), { tone: "hi", crease: "none" }) +
    S(G.disc(ECCENTRIC_X - MOTOR_X[0] - 2.4, ECCENTRIC_X - MOTOR_X[0], 5.2, MF, P, { bevel: 0.4 }), { tone: "hi", crease: "lo" }) +
    Dots([at([ECCENTRIC_X, 0, MOTOR_Z])], { size: 0.5, tone: "hi" }) +
    `<path data-part="eccentric" class="iso-line" data-tone="hi" d=""/>` +
    S(slab({ x: MOTOR_X[0] + 1.6, y: -1.4, w: 2.4, d: 2.8, r: 0.4 }, MOTOR_Z + 5.6, 1.6, 2), { tone: "mid" }) +
    S(slab({ x: MOTOR_X[0] + 1.6, y: 2.4, w: 2.4, d: 2.8, r: 0.4 }, MOTOR_Z + 5.2, 1.6, 2), { tone: "mid" }) +
    `</g>`,
);
front.push(`<g data-part="dipper-near" transform="${k.translateAlong(P, [0, restNear, 0])}">${dipper()}</g>`);
front.push(spring(SPRING_Y));

front.push(S(slab(railPlusY, Z.rail, Z.bed - Z.rail, 2, 0.4), { tone: "mid" }));
front.push(S(slab(railPlusX, Z.rail, Z.bed - Z.rail, 2, 0.4), { tone: "mid" }) + Dots([-80, 0, 80].map((y) => at([railPlusX.x + FRAME, y, Z.rail + 4.5])), { size: 0.42 }));
front.push(glassLines(slab({ x: OUT.x + 2.5, y: OUT.y + 2.5, w: OUT.w - 5, d: OUT.d - 5, r: 0 }, Z.bed, Z.floor - Z.bed, 1, 0)));
front.push(Ln(k.segment([IN.x, IN.y, Z.water], [IN.x + IN.w, IN.y, Z.water], P) + k.segment([IN.x, IN.y, Z.water], [IN.x, IN.y + IN.d, Z.water], P), { tone: "faint" }));
front.push(Ln(k.segment([IN.x + IN.w, IN.y, Z.water], [IN.x + IN.w, IN.y + IN.d, Z.water], P) + k.segment([IN.x, IN.y + IN.d, Z.water], [IN.x + IN.w, IN.y + IN.d, Z.water], P), { tone: "lo" }));
front.push(glassLines(slab({ x: IN.x + IN.w, y: IN.y, w: 4, d: IN.d, r: 0 }, Z.floor, Z.rim - Z.floor, 1, 0)));
front.push(glassLines(slab({ x: IN.x, y: IN.y + IN.d, w: IN.w, d: 4, r: 0 }, Z.floor, Z.rim - Z.floor, 1, 0)));
front.push(rimSvg(rimPlusY), rimSvg(rimPlusX));
front.push(...motorCableRim);
front.push(cornerPost(POST_PLAN(IN.x + IN.w, IN.y + IN.d)));
front.push(pillar(rimPlusY.y - 0.2));
front.push(
  S(slab(BEAM, BEAM_Z, BEAM_H, 3, 0.6), { tone: "mid" }) +
    Dots([BEAM.y + 5, -SPRING_Y, SPRING_Y, BEAM.y + BEAM.d - 5].map((y) => at([XS, y, BEAM_Z + BEAM_H])), { size: 0.45 }) +
    Ln(k.planOutline(k.insetPlan(BEAM, 2), BEAM_Z + BEAM_H, P, 2), { tone: "faint" }),
);

const startState = M.stateOf(START.frequency, START.spacing, optics);
const drop = Z.water - Z_PAPER;

const DATA = {
  W,
  H,
  P,
  camera: glCamera(P),
  range: RANGE,
  start: START,
  optics,
  beat: M.BEAT,
  slope: M.SLOPE,
  histDt: 0.05,
  phase: START_PHASE,
  pattern: PATTERN_GEO,
  reach: M.REACH,
  xs: XS,
  inside: [IN.x, IN.y, IN.x + IN.w, IN.y + IN.d],
  outside: [OUT.x, OUT.y, OUT.x + OUT.w, OUT.y + OUT.d],
  paper: [PAPER.x, PAPER.y, PAPER.x + PAPER.w, PAPER.y + PAPER.d],
  base: [BASE.x, BASE.y, BASE.x + BASE.w, BASE.y + BASE.d],
  round: [PAPER.r, BASE.r],
  z: [Z_PAPER, Z.rail, Z.water, Z.bed],
  top: Z.top,
  bed: Z.bed,
  floor: Z.floor,
  rim: Z.rim,
  lamp: LAMP,
  lampR: LAMP_R,
  blur: (0.5 * LAMP_R * drop) / (LAMP[2] - Z_PAPER),
  spread: BEAM_SPREAD,
  foam: [(Z.floor + Z.water) / 2, BEACH / 2, FOAM_PASS],
  beach: BEACH,
  legs: [LEG_AT[0], LEG_AT[1], LEG_R, Z.rail],
  post: [POST[0], POST[1], POST_R, POST_TOP],
  bar: [BAR.x - 1.6, BAR.y, BAR.x + BAR.w + 1.6, BAR.y + BAR.d],
  beam: [BEAM.x, BEAM.y, BEAM.x + BEAM.w, BEAM.y + BEAM.d],
  motor: [MOTOR_X[0] - 1.6, -6, ECCENTRIC_X, 6],
  high: [BAR_Z + BAR_H / 2, BEAM_Z + BEAM_H / 2, MOTOR_Z],
  box: [BOX.x, BOX.y, BOX.x + BOX.w, BOX.y + BOX.d],
  boxTop: BOX_TOP,
  near: [IN.x + IN.w, IN.y + IN.d],
  contact: CONTACT_R + 0.35,
  eccentric: [ECCENTRIC_X, MOTOR_Z],
  knob: KNOB,
  meter: METER,
};

const LABEL_TEXT =
  "A ripple tank on a base plate: a shallow glass-bottomed tank of water standing on four tall legs with knurled levelling screws, high over a paper screen. A strobe lamp on an arm from a post behind the tank shines down through the water. A bridge across the tank carries a vibrating bar on two coil springs, shaken by a small motor with an eccentric; two point dippers on the bar touch the water. A control box beside the paper sets the frequency. The two sets of circular ripples interfere; the water's surface sheen moves with them, and the lamp projects the pattern of bright crest lines and grey nodal lines onto the paper far below.";

const backSvg = k.figureSvg({ width: W, height: H, label: LABEL_TEXT, body: back, className: "rt-back" });
const layerSvg = (name, body) => `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg rt-layer ${name}" viewBox="0 0 ${W} ${H}" aria-hidden="true">${body.join("")}</svg>`;
const stage = `<div class="rt-stage" tabindex="0" role="group" aria-roledescription="ripple tank control" aria-label="Ripple tank. Move the pointer across the tank to set the frequency and up or down to set the dipper spacing; or use the left and right arrow keys for frequency and up and down for spacing, with Shift for bigger steps."><span class="rt-said" aria-live="polite"></span>${backSvg}<canvas class="rt-layer rt-gl rt-paper-gl" aria-hidden="true"></canvas>${layerSvg("rt-mid", mid)}<canvas class="rt-layer rt-gl rt-water-gl" aria-hidden="true"></canvas>${layerSvg("rt-front", front)}</div>`;

const at16 = M.stateOf(16, 50, optics);
const at28 = M.stateOf(28, 50, optics);
const cm = (mm) => (mm / 10).toFixed(1);
const caption = `A ripple tank seen by strobe light. Two dippers on one vibrating bar touch 6 mm of water in phase; the lamp flashes ${M.BEAT} times a second slower than they dip, so each wave seems to crawl outward at ${M.BEAT} Hz, which is honest slow motion. Ripples this small are held flat by surface tension as well as gravity, ω² = (gk + σk³/ρ) tanh kh, so at 16 Hz the wavelength is ${(at16.lambdaMm / 10).toFixed(2)} cm and a crest travels ${(at16.speed * 100).toFixed(0)} cm/s. Wherever the paths from the two dippers differ by half a wavelength the waves cancel, so dippers d apart draw 2⌊d/λ + ½⌋ nodal lines: ${at16.nodal} at 5 cm. A crest one wavelength out stands only ${at16.amplitude.toFixed(3)} mm high (ka = ${M.SLOPE}), yet it is a weak lens. The lamp is ${cm(LAMP[2] - Z.water)} cm above the water and the paper ${cm(drop)} cm below it, so the shadow is ${optics.magnification.toFixed(2)} times life size and the paper sits ${at16.focus.toFixed(2)} of a crest's focal length down at 16 Hz, ${at28.focus.toFixed(2)} at 28 Hz. Near the dippers, where the two waves add, the light passes through focus and every crest draws a sharp bright line, and the plain grey bands that break them are the nodal lines. A sloped foam beach inside the walls soaks up each wave before it can reflect. The damping here is clean water's viscosity alone, so these ripples carry farther than in a real tank, where a surface film calms them sooner.`;

const body = `<div class="rt">${k.plateHtml({
  fig: "Fig 3",
  title: "Ripple tank",
  hint: "Move across for f · up and down for spacing",
  readout: M.readoutOf(startState),
  keys: [
    { mark: "lit", label: "Two dippers, in phase: the sources" },
    { mark: "raised", label: "Tank on legs, bridge and vibrating bar" },
    { mark: "flat", label: `The paper, ${cm(drop)} cm down: the pattern ${optics.magnification.toFixed(2)}× life size` },
  ],
  caption,
  body: stage,
})}</div>`;

const MODEL = readFileSync(join(HERE, "model.mjs"), "utf8").replace(/^export /gm, "");
const CSS = readFileSync(join(HERE, "ripple.css"), "utf8");
const LIVE = readFileSync(join(HERE, "live.js"), "utf8").replace("__DATA__", JSON.stringify(DATA));
const page = k.pageHtml({ title: "Ripple tank", theme, body: `<style>${CSS}</style>${body}`, script: kitScript() + glScript() + MODEL + LIVE, width: 760 });
const name = theme === "light" ? "ripple-tank-light.html" : "ripple-tank.html";
writeFileSync(join(HERE, name), page);
console.log("wrote", name, `${(page.length / 1024).toFixed(0)} KB`, `${back.length + mid.length + front.length} layers`, `M ${optics.magnification.toFixed(3)} caustic ${optics.caustic.toFixed(2)} mm x16 ${at16.focus.toFixed(3)} x28 ${at28.focus.toFixed(3)}`);
```

## examples/ripple-tank/live.js

```js
const D = __DATA__;
const root = document.querySelector(".rt");
const stage = root.querySelector(".rt-stage");
const readout = root.querySelector("[data-readout]");
const paperCanvas = stage.querySelector(".rt-paper-gl");
const waterCanvas = stage.querySelector(".rt-water-gl");
const front = stage.querySelector(".rt-front");
const mid = stage.querySelector(".rt-mid");
const farDipper = front.querySelector('[data-part="dipper-far"]');
const nearDipper = front.querySelector('[data-part="dipper-near"]');
const eccentric = front.querySelector('[data-part="eccentric"]');
const pointer = mid.querySelector('[data-part="pointer"]');
const needle = mid.querySelector('[data-part="needle"]');
const bar = front.querySelector('[data-part="bar"]');
const motor = front.querySelector('[data-part="motor"]');
const said = stage.querySelector(".rt-said");
const crestPath = stage.querySelector('[data-part="crests"]');
const nodalPath = stage.querySelector('[data-part="nodal"]');
const still = matchMedia("(prefers-reduced-motion: reduce)");
const pale = !!root.closest('[data-theme="light"]');
const TAU = Math.PI * 2;
const HIST = 32;
const clampTo = (value, low, high) => Math.min(high, Math.max(low, value));

const WAVE_GLSL = `
uniform vec2 uS1;uniform vec2 uS2;uniform float uPhase;uniform float uReach;
uniform sampler2D uHT;uniform vec4 uLo;uniform vec4 uSpan;uniform vec2 uHist;uniform float uIG;uniform float uKNow;uniform float uStep;
uniform float uSlope;uniform float uCaustic;uniform float uMag;
uniform vec4 uIn;uniform float uBeach;uniform vec4 uZ;uniform float uLight;uniform float uPale;uniform float uBed;uniform vec3 uLamp;uniform float uSpread;
const float HN=${HIST}.;
float gPx;vec2 gGrad;vec3 gHess;float gCurv;float gAmp;float gKA;float gMove;
float coneOf(vec3 p){float drop=uLamp.z-p.z;float spread=13.4+drop*uSpread;return 1.-smoothstep(spread*.72,spread,length(p.xy-uLamp.xy));}
float sdRect(vec2 p,vec4 r,float rad){vec2 c=.5*(r.xy+r.zw);vec2 h=.5*(r.zw-r.xy)-rad;vec2 q=abs(p-c)-h;return length(max(q,0.))+min(max(q.x,q.y),0.)-rad;}
float inRect(vec2 p,vec4 r,float rad,float soft){return 1.-smoothstep(-soft,soft,sdRect(p,r,rad));}
vec2 unpack(vec4 t){t=floor(t*255.+.5);return vec2(t.x*256.+t.y,t.z*256.+t.w)/65535.;}
float slotOf(float age){float s=age/uHist.y;return clamp(mix(s/max(uHist.x,1e-3),1.+s-uHist.x,step(uHist.x,s)),0.,HN-1.);}
vec2 rowAt(float u,float row){
  float n=floor(u);
  vec2 a=unpack(texture2D(uHT,vec2((n+.5)/HN,row)));
  vec2 b=unpack(texture2D(uHT,vec2((min(n+1.,HN-1.)+.5)/HN,row)));
  return mix(a,b,u-n);
}
void source(vec2 p,vec2 s,float scale){
  vec2 q=p-s;float r2=dot(q,q)+4.;float r=sqrt(r2);
  float ig=uLo.w+uSpan.w*rowAt(slotOf(r*uIG),.75).y;
  float u=slotOf(r*ig);
  vec2 kp=uLo.xy+uSpan.xy*rowAt(u,.25);
  float kE=kp.x;
  float dec=uLo.z+uSpan.z*rowAt(u,.75).x;
  float fine=smoothstep(2.5,5.,6.2832/kE*scale/gPx);
  float inv=1./(r+uReach);
  float A=uSlope/kE*fine*sqrt((uReach+6.2832/kE)*inv)*exp(-r*dec);
  float a1=-.5*inv-dec;
  float a2=a1*a1+.5*inv*inv;
  float th=kE*r-uPhase+kp.y;float cs=cos(th);float sn=sin(th);
  float g1=A*(a1*cs-kE*sn);
  float g2=A*(a2*cs-2.*a1*kE*sn-kE*kE*cs);
  vec2 n=q/r;float t=g1/r;
  gGrad+=g1*n;
  gHess+=vec3((g2-t)*n.x*n.x+t,(g2-t)*n.x*n.y,(g2-t)*n.y*n.y+t);
  gCurv+=A*kE*kE;
  gAmp+=A;
  gKA+=A*kE;
  gMove=max(gMove,abs(uKNow-kE)/max(ig,1e-6)*uStep/kE*fine);
}
void field(vec2 p,float scale){
  gGrad=vec2(0.);gHess=vec3(0.);gCurv=0.;gAmp=0.;gKA=0.;gMove=0.;
  source(p,uS1,scale);source(p,uS2,scale);
  float w=smoothstep(0.,uBeach,-sdRect(p,uIn,0.));
  gGrad*=w;gHess*=w;gCurv*=w;gAmp*=w;gKA*=w;gMove*=w;
}
vec3 dither(vec3 c){
  float m=max(c.r,max(c.g,c.b));
  float n=(hash12(gl_FragCoord.xy)+hash12(gl_FragCoord.yx*1.37+17.)-1.)/255.;
  return max(c+n*smoothstep(0.,1.5/255.,m),0.);
}
`;

const PACK = [
  [0.15, 0.85],
  [-256, 256],
  [0, 0.004],
  [0.0025, 0.005],
];

function textureOf(layer) {
  const gl = layer.gl;
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, HIST, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  return texture;
}

const PAPER_GLSL = `${WAVE_GLSL}
uniform vec4 uOut;uniform float uLampR;uniform vec4 uPaper;uniform vec4 uBase;uniform vec2 uRound;uniform vec4 uLegs;uniform vec4 uPost;
uniform vec4 uBar;uniform vec4 uBeam;uniform vec4 uMotor;uniform vec3 uHigh;uniform vec4 uBox;uniform float uBoxTop;uniform float uBlur;uniform vec3 uExpo;uniform vec3 uFoam;
float gPen;
vec2 hitAt(vec3 q,float z){return uLamp.xy+(q.xy-uLamp.xy)*(uLamp.z-z)/(uLamp.z-q.z);}
float penAt(vec3 q,float z){return max(uLampR*(z-q.z)/(uLamp.z-q.z),gPen);}
float rodLight(vec3 q,vec2 c,float rad,float top){
  vec2 ab=uLamp.xy-q.xy;float h=(top-q.z)/(uLamp.z-q.z);
  float t=clamp(dot(c-q.xy,ab)/max(dot(ab,ab),1e-4),0.,h);
  float pen=max(uLampR*t,gPen);
  return smoothstep(rad-pen,rad+pen,length(q.xy+ab*t-c));
}
float legClear(vec3 q,vec2 c){
  vec3 v=towardViewer();
  float top=(uLegs.w-q.z)/v.z;
  float t=clamp(dot(c-q.xy,v.xy)/max(dot(v.xy,v.xy),1e-4),0.,top);
  return smoothstep(uLegs.z+3.,uLegs.z+7.,length(q.xy+v.xy*t-c));
}
float causticAt(vec2 u){
  vec2 u0=u;
  field(u0,uMag);u0=u-uCaustic*gGrad;
  field(u0,uMag);u0=u-uCaustic*gGrad;
  field(u0,uMag);
  float c=uCaustic;
  float J=(1.+c*gHess.x)*(1.+c*gHess.z)-c*c*gHess.y*gHess.y;
  float b=sqrt(uBlur*uBlur+pow(.55*gPx/uMag,2.)+pow(.5*gMove,2.));
  float e=.6*pow(max(gKA/max(gAmp,1e-6)*b,1e-4),.6667);
  return sqrt(1.+e*e)/sqrt(J*J+e*e);
}
float lampAt(vec3 q){
  vec3 d=uLamp-q;float fall=pow(d.z/length(d),3.)*coneOf(q);
  float through=inRect(hitAt(q,uZ.y),uIn,0.,penAt(q,uZ.y));
  through*=mix(uFoam.z,1.,inRect(hitAt(q,uFoam.x),uIn+vec4(uFoam.y,uFoam.y,-uFoam.y,-uFoam.y),0.,max(penAt(q,uFoam.x),uFoam.y*.5)));
  float open=1.-inRect(hitAt(q,uZ.w),uOut,1.5,penAt(q,uZ.w));
  float caustic=1.;
  if(through>.001)caustic=causticAt(hitAt(q,uZ.z));
  float shade=rodLight(q,vec2(uLegs.x,uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(-uLegs.x,uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(uLegs.x,-uLegs.y),uLegs.z,uLegs.w)*rodLight(q,vec2(-uLegs.x,-uLegs.y),uLegs.z,uLegs.w);
  shade*=rodLight(q,uPost.xy,uPost.z,uPost.w);
  shade*=1.-.96*inRect(hitAt(q,uHigh.x),uBar,1.,penAt(q,uHigh.x));
  shade*=1.-.96*inRect(hitAt(q,uHigh.y),uBeam,1.,penAt(q,uHigh.y));
  shade*=1.-.96*inRect(hitAt(q,uHigh.z),uMotor,3.,penAt(q,uHigh.z));
  shade*=1.-.96*inRect(hitAt(q,uBoxTop),uBox,4.,penAt(q,uBoxTop));
  return fall*shade*(through*caustic*mix(.6,1.1,clamp(caustic-1.,0.,1.))*.9+open);
}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  gPx=pixelOf()/uCam.z;
  float soft=.7*gPx;
  vec3 q=onFloor(vb,uZ.x);
  vec2 rest=q.xy;
  vec3 s=onFloor(vb,uZ.z);
  float wet=inRect(s.xy,uIn,0.,soft);
  vec2 shift=vec2(0.);
  if(wet>.001){
    field(s.xy,1.);
    vec3 n=normalize(vec3(-gGrad,1.));
    vec3 d=-towardViewer();
    vec3 inWater=refract(d,n,1./1.333);
    vec3 flatWater=refract(d,vec3(0.,0.,1.),1./1.333);
    vec3 below=refract(inWater,vec3(0.,0.,1.),1.333);
    shift=(inWater.xy/-inWater.z-flatWater.xy/-flatWater.z)*(uZ.z-uBed)+(below.xy/-below.z-d.xy/-d.z)*(uBed-uZ.x);
    shift*=wet*legClear(q,vec2(uLegs.x,uLegs.y))*legClear(q,vec2(-uLegs.x,uLegs.y))*legClear(q,vec2(uLegs.x,-uLegs.y))*legClear(q,vec2(-uLegs.x,-uLegs.y));
  }
  q.xy+=shift;
#ifdef GL_OES_standard_derivatives
  float stretch=length(fwidth(q.xy))/max(length(fwidth(rest)),1e-6);
#else
  float stretch=1.+8.*length(shift)/max(uZ.z-uZ.x,1.);
#endif
  gPen=1.5*soft*max(stretch,1.);
  float base=inRect(q.xy,uBase,uRound.y,soft);
  if(base<.001){gl_FragColor=vec4(0.);return;}
  float paper=inRect(q.xy,uPaper,uRound.x,soft);
  float e=lampAt(q)*mix(1.,.84,wet);
  if(uPale>.5){
    float lit=mix(1.-.05*exp(-e),1.-uExpo.x/(1.+pow(e/uExpo.y,uExpo.z)),paper);
    float off=(1.-mix(.95,1.-uExpo.x,paper))/.89*base;
    float ink=mix(off,(1.-lit)/.89*base,uLight);
    gl_FragColor=vec4(dither(vec3(.11,.11,.12)*ink),ink);
  }else{
    float glow=(1.-exp(-uExpo.x*e))*mix(.34,.92,paper)*base*uLight;
    vec3 c=dither(vec3(glow));
    gl_FragColor=vec4(c,c.r);
  }
}`;

const WATER_GLSL = `${WAVE_GLSL}
uniform vec2 uNear;uniform float uContact;uniform float uFloor;uniform float uRim;uniform float uTop;uniform vec4 uEnv;uniform vec3 uGlint;uniform float uTint;uniform vec4 uWall;uniform vec2 uGlass;
float beamAt(vec2 vb){
  float sum=0.;
  float jitter=hash12(gl_FragCoord.xy);
  for(int i=0;i<28;i++){
    float z=mix(uTop+1.,uLamp.z,(float(i)+jitter)/28.);
    vec3 p=onFloor(vb,z);
    float drop=uLamp.z-z;
    sum+=coneOf(p)*smoothstep(0.,16.,drop)/(1.+drop*drop/6400.);
  }
  return sum/28.;
}
float envAt(vec3 r,float blur){
  float el=asin(clamp(r.z,-1.,1.));
  float w=sqrt(uEnv.w*uEnv.w+blur*blur);
  float g=sqrt(uGlint.y*uGlint.y+blur*blur);
  float glint=(uGlint.y/g)*exp(-pow((el-asin(uCam.z)-uGlint.x)/g,2.));
  return mix(uEnv.x,uEnv.y,smoothstep(-w,w,el-asin(uCam.z)))+uEnv.z*smoothstep(-.3,1.2,el)+uGlint.z*glint;
}
float lineAt(float x,float at,float width){return exp(-pow((x-at)/width,2.))/(width*1.772);}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  float px=pixelOf();
  gPx=px/uCam.z;
  float soft=.7*gPx;
  vec3 s=onFloor(vb,uZ.z);
  float wet=inRect(s.xy,uIn,0.,soft);
  vec3 v=towardViewer();
  float add=0.;
  float tint=0.;
  if(wet>.001){
    field(s.xy,1.);
    float lc=max(2.2,1.6*gPx);
    vec4 dw=vec4(s.x-uIn.x,uIn.z-s.x,s.y-uIn.y,uIn.w-s.y);
    vec4 tilt=1.1*exp(-max(dw,0.)/lc);
    vec2 g=-gGrad+vec2(tilt.x-tilt.y,tilt.z-tilt.w);
    vec3 n=normalize(vec3(g,1.));
    float cosI=max(dot(n,v),0.);
    float fres=.02+.98*pow(1.-cosI,5.);
    vec3 r=reflect(-v,n);
    float spread=2.*gPx*(gCurv+dot(tilt,vec4(1.))/lc);
    float refl=fres*envAt(r,spread);
    float flat0=(.02+.98*pow(1.-uCam.z,5.))*envAt(vec3(-v.xy,v.z),0.);
    float rw=max(.45,1.2*soft);
    float rc=uContact+rw;
    float ring=(exp(-pow((length(s.xy-uS1)-rc)/rw,2.))+exp(-pow((length(s.xy-uS2)-rc)/rw,2.)))*(.45/rw);
    float lift=uPale>.5?max(refl-flat0,0.)/.15:refl;
    float sink=uPale>.5?max(flat0-refl,0.)/.74:0.;
    add+=(lift+ring*.12)*wet;
    tint+=(uTint+sink+ring*.05)*wet;
  }
  float pz=px/uCam.w;
  float sz=.7*pz;
  vec3 bx=onWallX(vb,uNear.x);
  vec3 by=onWallY(vb,uNear.y);
  float alongX=window(bx.y,uIn.y,uIn.w,.7*px/uCam.y);
  float alongY=window(by.x,uIn.x,uIn.z,.7*px/uCam.x);
  float bandX=window(bx.z,uFloor,uZ.z,sz)*alongX;
  float bandY=window(by.z,uFloor,uZ.z,sz)*alongY;
  float band=max(bandX,bandY);
  vec3 fx=onWallX(vb,uIn.x);
  vec3 fy=onWallY(vb,uIn.y);
  float glass=max(window(bx.z,uZ.z,uRim,sz)*alongX,window(by.z,uZ.z,uRim,sz)*alongY);
  glass=max(glass,max(window(fx.z,uZ.z,uRim,sz)*window(fx.y,uIn.y,uIn.w,.7*px/uCam.y),window(fy.z,uZ.z,uRim,sz)*window(fy.x,uIn.x,uIn.z,.7*px/uCam.x)));
  add+=glass*uGlass.x;
  tint+=glass*uGlass.y;
  float lw=max(.45,.9*pz);
  float lip=max(lineAt(bx.z,uZ.z+.5,lw)*alongX,lineAt(by.z,uZ.z+.5,lw)*alongY)*1.2;
  float sole=max(lineAt(bx.z,uFloor+.3,lw)*alongX,lineAt(by.z,uFloor+.3,lw)*alongY)*1.2;
  add+=band*uWall.x+(lip+sole*.35)*uWall.z;
  tint+=band*uWall.y+(lip+sole*.35)*uWall.w;
  if(uPale<.5)add+=tonemap(vec3(beamAt(vb)*.16),1.).r*.9;
  add=max(add,0.);tint=clamp(tint,0.,.9);
  float a=add+tint;
  float over=max(a,1.);
  vec3 ink=uPale>.5?vec3(.11,.11,.12):vec3(.02,.025,.03);
  vec3 c=dither(vec3(add)+ink*tint)/over;
  gl_FragColor=vec4(c,a/over)*uLight;
}`;

const FIXED = {
  uReach: D.reach,
  uSlope: D.slope,
  uCaustic: D.optics.caustic,
  uMag: D.optics.magnification,
  uIn: D.inside,
  uBeach: D.beach,
  uZ: D.z,
  uPale: pale ? 1 : 0,
  uBed: D.bed,
  uFloor: D.floor,
  uOut: D.outside,
  uLamp: D.lamp,
  uLampR: D.lampR,
  uBlur: D.blur,
  uPaper: D.paper,
  uBase: D.base,
  uRound: D.round,
  uLegs: D.legs,
  uPost: D.post,
  uBar: D.bar,
  uBeam: D.beam,
  uMotor: D.motor,
  uHigh: D.high,
  uBox: D.box,
  uBoxTop: D.boxTop,
  uNear: D.near,
  uContact: D.contact,
  uSpread: D.spread,
  uFoam: D.foam,
  uLo: PACK.map(([low]) => low),
  uSpan: PACK.map(([low, high]) => high - low),
  uExpo: pale ? [0.3, 0.7, 2.4] : [0.55, 0, 0],
  uEnv: pale ? [0.3, 1, 0, 0.02] : [0.04, 6, 0.3, 0.02],
  uGlass: pale ? [0, 0.05] : [0.02, 0.03],
  uGlint: pale ? [0.035, 0.006, 3] : [0.035, 0.006, 26],
  uRim: D.rim,
  uTop: D.top,
  uTint: pale ? 0.06 : 0.08,
  uWall: pale ? [0, 0.2, 0, 0.45] : [0.05, 0.16, 0.5, 0],
};
const NAMES = ["uS1", "uS2", "uPhase", "uHist", "uIG", "uKNow", "uStep", "uLight"];
const paperLayer = glLayer({ canvas: paperCanvas, fragment: PAPER_GLSL, view: [D.W, D.H], camera: D.camera, uniforms: NAMES, fixed: FIXED, extensions: ["GL_OES_standard_derivatives"] });
const waterLayer = paperLayer ? glLayer({ canvas: waterCanvas, fragment: WATER_GLSL, view: [D.W, D.H], camera: D.camera, uniforms: NAMES, fixed: FIXED }) : null;
const lit = !!(paperLayer && waterLayer);
const bytes = new Uint8Array(HIST * 2 * 4);
const textures = lit ? [paperLayer, waterLayer].map(textureOf) : [];
if (lit) stage.setAttribute("data-gl", "");

const FR = D.range.frequency;
const SR = D.range.spacing;
const freq = { x: D.start.frequency, v: 0 };
const gap = { x: D.start.spacing, v: 0 };
const state = { tf: D.start.frequency, td: D.start.spacing, phase: D.phase, lamp: 0, lampClock: 0, clock: 0, pushed: 0, step: 0 };
let model = stateOf(freq.x, gap.x, D.optics);
const history = { k: new Float64Array(HIST), phi: new Float64Array(HIST), omega: new Float64Array(HIST), decay: new Float64Array(HIST), slow: new Float64Array(HIST), at: new Float64Array(HIST) };
let phi = 0;
let touring = !still.matches;
let tourClock = -1.4;
let tourFrom = { f: freq.x, d: gap.x };
let idleTimer = 0;
let sayTimer = 0;
let visible = false;
let frame = 0;
let last = 0;
let dirty = true;
let drawn = "";

const angleOf = (f) => ((-135 + (270 * (f - FR[0])) / (FR[1] - FR[0])) * Math.PI) / 180;

function tourAt(time) {
  const f = 17 + 8.5 * Math.sin((time * TAU) / 28 - 0.3);
  const d = 50 + 25 * Math.sin((time * TAU) / 16 + 0.2);
  const blend = smooth(0, 1, time / 2.4);
  return { f: tourFrom.f + (f - tourFrom.f) * blend, d: tourFrom.d + (d - tourFrom.d) * blend };
}

function announce() {
  clearTimeout(sayTimer);
  sayTimer = setTimeout(() => {
    const words = wordsOf(stateOf(state.tf, state.td, D.optics));
    if (said.textContent !== words) said.textContent = words;
  }, 700);
}

function record(slot) {
  history.k[slot] = model.kMm;
  history.phi[slot] = phi;
  history.omega[slot] = TAU * model.frequency;
  history.decay[slot] = model.decay;
  history.slow[slot] = 1 / (model.group * 1000);
  history.at[slot] = state.clock;
}

function remember(dt, calm) {
  state.clock += dt;
  phi += TAU * model.frequency * dt;
  if (calm) {
    for (let slot = 0; slot < HIST; slot++) record(slot);
    state.pushed = state.clock;
    return;
  }
  while (state.clock - state.pushed >= D.histDt) {
    state.pushed += D.histDt;
    for (const list of Object.values(history)) list.copyWithin(1, 0, HIST - 1);
  }
  record(0);
}

function put(index, value, [low, high]) {
  const code = Math.round(clampTo((value - low) / (high - low), 0, 1) * 65535);
  bytes[index] = code >> 8;
  bytes[index + 1] = code & 255;
}

function packHistory() {
  for (let slot = 0; slot < HIST; slot++) {
    const lag = phi - history.phi[slot] - history.omega[slot] * (state.clock - history.at[slot]);
    put(slot * 4, history.k[slot], PACK[0]);
    put(slot * 4 + 2, slot ? lag : 0, PACK[1]);
    put((HIST + slot) * 4, history.decay[slot], PACK[2]);
    put((HIST + slot) * 4 + 2, history.slow[slot], PACK[3]);
  }
  for (const [index, layer] of [paperLayer, waterLayer].entries()) {
    const gl = layer.gl;
    gl.bindTexture(gl.TEXTURE_2D, textures[index]);
    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, HIST, 2, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
  }
}

function drawFallback() {
  if (lit) return;
  const key = `${freq.x.toFixed(2)}|${gap.x.toFixed(1)}`;
  if (key === drawn) return;
  drawn = key;
  const pattern = patternOf(model, D.pattern, (point) => iso(point, D.P), D.phase);
  crestPath.setAttribute("d", pattern.crests);
  nodalPath.setAttribute("d", pattern.nodal);
}

function drawSvg() {
  const half = gap.x / 2;
  const bob = model.amplitude * Math.sin(state.phase);
  farDipper.setAttribute("transform", translateAlong(D.P, [0, -half, bob]));
  nearDipper.setAttribute("transform", translateAlong(D.P, [0, half, bob]));
  bar.setAttribute("transform", translateAlong(D.P, [0, 0, bob]));
  motor.setAttribute("transform", translateAlong(D.P, [0, 0, bob]));
  const a = angleOf(freq.x);
  const [kx, ky] = D.knob;
  pointer.setAttribute("d", lineOnTop([kx + Math.sin(a) * 1.4, ky - Math.cos(a) * 1.4], [kx + Math.sin(a) * 5.6, ky - Math.cos(a) * 5.6], D.boxTop + 8, D.P));
  const [mx, my] = D.meter;
  needle.setAttribute("d", lineOnTop([mx - Math.sin(a) * 2.2, my + Math.cos(a) * 2.2], [mx + Math.sin(a) * 9.6, my - Math.cos(a) * 9.6], D.boxTop + 3.4, D.P));
  const [ex, ez] = D.eccentric;
  eccentric.setAttribute("d", sideRing([ex + 0.05, 2.9 * Math.cos(state.phase), ez + bob + 2.9 * Math.sin(state.phase)], 1.05, "x", D.P, 14));
  stage.style.setProperty("--rt-lamp", state.lamp.toFixed(3));
  drawFallback();
}

function drawGl() {
  if (!lit || !paperCanvas.clientWidth || !paperCanvas.clientHeight) return;
  if (state.lamp <= 0 && !pale) {
    paperLayer.clear();
    waterLayer.clear();
    return;
  }
  packHistory();
  const values = {
    uS1: [D.xs, -gap.x / 2],
    uS2: [D.xs, gap.x / 2],
    uPhase: state.phase,
    uHist: [(state.clock - state.pushed) / D.histDt, D.histDt],
    uIG: 1 / (model.group * 1000),
    uKNow: model.kMm,
    uStep: state.step,
    uLight: state.lamp,
  };
  paperLayer.draw(values, 0);
  if (state.lamp > 0) waterLayer.draw(values, 0);
  else waterLayer.clear();
}

function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  const calm = still.matches;
  if (touring && !calm) {
    tourClock += dt;
    if (tourClock >= 0) {
      const aim = tourAt(tourClock);
      state.tf = aim.f;
      state.td = aim.d;
    }
  }
  const before = [freq.x, gap.x];
  settle(freq, state.tf, dt, 0.625, calm);
  settle(gap, state.td, dt, 0.4, calm);
  if (lit) {
    state.lampClock += dt;
    state.lamp = calm ? 1 : smooth(0, 1, (state.lampClock - 0.15) / 0.9);
  }
  if (!calm) state.phase = (state.phase + dt * TAU * D.beat) % TAU;
  state.step = calm ? 0 : dt;
  const changed = dirty || before[0] !== freq.x || before[1] !== gap.x;
  if (changed) model = stateOf(freq.x, gap.x, D.optics);
  remember(dt, calm);
  if (changed || !calm) {
    drawSvg();
    drawGl();
    if (readout.textContent !== readoutOf(model)) readout.textContent = readoutOf(model);
  }
  dirty = false;
  const settled = calm && freq.x === state.tf && gap.x === state.td && (!lit || state.lamp === 1);
  frame = visible && !settled ? requestAnimationFrame(tick) : 0;
}

function run() {
  if (!frame && visible) {
    last = 0;
    frame = requestAnimationFrame(tick);
  }
}

function hold() {
  touring = false;
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    if (still.matches) return;
    touring = true;
    tourClock = -0.001;
    tourFrom = { f: freq.x, d: gap.x };
    run();
  }, 3600);
}

function steer(f, d) {
  if (touring) {
    freq.v = 0;
    gap.v = 0;
  }
  state.tf = clampTo(f, FR[0], FR[1]);
  state.td = clampTo(d, SR[0], SR[1]);
  dirty = true;
  hold();
  announce();
  run();
}

new IntersectionObserver(
  (entries) => {
    visible = entries[entries.length - 1].isIntersecting;
    if (visible) run();
  },
  { rootMargin: "120px 0px" },
).observe(stage);

function aimOf(event) {
  const box = stage.getBoundingClientRect();
  const fx = clampTo(((event.clientX - box.left) / box.width - 0.12) / 0.76, 0, 1);
  const fy = 1 - clampTo(((event.clientY - box.top) / box.height - 0.16) / 0.66, 0, 1);
  return [FR[0] + fx * (FR[1] - FR[0]), SR[0] + fy * (SR[1] - SR[0])];
}

stage.addEventListener("pointermove", (event) => {
  if (event.pointerType === "touch") {
    if (!event.buttons) return;
    steer(aimOf(event)[0], touring ? gap.x : state.td);
    return;
  }
  const [f, d] = aimOf(event);
  steer(f, d);
});

stage.addEventListener("pointerdown", (event) => {
  const [f, d] = aimOf(event);
  steer(f, d);
});

stage.addEventListener("keydown", (event) => {
  const big = event.shiftKey;
  let f = touring ? freq.x : state.tf;
  let d = touring ? gap.x : state.td;
  if (event.key === "ArrowRight") f += big ? 2 : 0.5;
  else if (event.key === "ArrowLeft") f -= big ? 2 : 0.5;
  else if (event.key === "ArrowUp") d += big ? 10 : 2;
  else if (event.key === "ArrowDown") d -= big ? 10 : 2;
  else if (event.key === "Home") f = FR[0];
  else if (event.key === "End") f = FR[1];
  else return;
  event.preventDefault();
  steer(f, d);
});

still.addEventListener("change", () => {
  touring = !still.matches;
  dirty = true;
  run();
});

if (matchMedia("(hover: none)").matches) {
  const hint = root.querySelector('[data-corner="hint"]');
  if (hint) hint.textContent = "Tap to set f and spacing · drag across for f";
}
window.addEventListener("resize", () => {
  dirty = true;
  run();
});
stage.addEventListener("focus", () => {
  said.textContent = wordsOf(model);
});
for (let slot = 0; slot < HIST; slot++) record(slot);
drawSvg();
drawGl();
readout.textContent = readoutOf(model);
```

## examples/ripple-tank/model.mjs

```js
export const WATER = { depth: 0.006, gravity: 9.81, tension: 0.0728, density: 998, index: 1.333, viscosity: 1.0e-6 };
export const GLASS = 1.52;
export const RANGE = { frequency: [8, 28], spacing: [20, 80] };
export const BEAT = 0.75;
export const SLOPE = 0.03;
export const REACH = 24;

export function omegaOf(k) {
  const { depth, gravity, tension, density } = WATER;
  return Math.sqrt((gravity * k + (tension / density) * k * k * k) * Math.tanh(k * depth));
}

export function wavenumberOf(frequency) {
  const omega = 2 * Math.PI * frequency;
  let low = 1;
  let high = 20000;
  for (let step = 0; step < 60; step++) {
    const mid = (low + high) / 2;
    if (omegaOf(mid) < omega) low = mid;
    else high = mid;
  }
  return (low + high) / 2;
}

export function waveOf(frequency) {
  const k = wavenumberOf(frequency);
  const lambda = (2 * Math.PI) / k;
  const speed = (2 * Math.PI * frequency) / k;
  const h = 1e-3 * k;
  const group = (omegaOf(k + h) - omegaOf(k - h)) / (2 * h);
  const { viscosity, depth } = WATER;
  const omega = 2 * Math.PI * frequency;
  const damping = 2 * viscosity * k * k + (k * Math.sqrt((viscosity * omega) / 2)) / Math.sinh(2 * k * depth);
  return { frequency, k, lambda, speed, group, damping, decay: damping / group };
}

export function nodalCount(spacing, lambda) {
  return 2 * Math.floor(spacing / lambda + 0.5);
}

export function nodalVertices(spacing, lambda) {
  const out = [];
  for (let m = 0; (m + 0.5) * lambda < spacing; m++) {
    out.push(((m + 0.5) * lambda) / 2, -((m + 0.5) * lambda) / 2);
  }
  return out.sort((a, b) => a - b);
}

export const magnificationOf = (zLamp, zPaper, zWater) => (zLamp - zPaper) / (zLamp - zWater);

export function causticOf(zLamp, zPaper, zWater, zFloor = zWater, zBed = zFloor) {
  const n = WATER.index;
  const bend = (zWater - zFloor) * (1 - 1 / n) + ((zFloor - zBed) * (n - 1)) / GLASS + (zBed - zPaper) * (n - 1);
  return bend / magnificationOf(zLamp, zPaper, zWater);
}

export const amplitudeOf = (kMm) => SLOPE / kMm;

export const focusOf = (kMm, caustic) => caustic * amplitudeOf(kMm) * kMm * kMm;

export function stateOf(frequency, spacingMm, optics) {
  const wave = waveOf(frequency);
  const lambdaMm = wave.lambda * 1000;
  const kMm = wave.k / 1000;
  const amplitude = amplitudeOf(kMm);
  return {
    frequency,
    spacing: spacingMm,
    lambdaMm,
    kMm,
    speed: wave.speed,
    group: wave.group,
    decay: wave.decay / 1000,
    nodal: nodalCount(spacingMm, lambdaMm),
    strobe: frequency - BEAT,
    amplitude,
    focus: focusOf(kMm, optics.caustic),
    projected: lambdaMm * optics.magnification,
  };
}

export const readoutOf = (state) => `f ${state.frequency.toFixed(1)} Hz · λ ${(state.lambdaMm / 10).toFixed(2)} cm · d ${(state.spacing / 10).toFixed(1)} cm · ${state.nodal} nodal lines`;

export function wordsOf(state) {
  return `${state.frequency.toFixed(1)} hertz, wavelength ${(state.lambdaMm / 10).toFixed(2)} centimetres, dippers ${(state.spacing / 10).toFixed(1)} centimetres apart, ${state.nodal} nodal lines`;
}

function clippedRunsOf(points, [x0, y0, x1, y1], project) {
  const runs = [];
  let run = [];
  for (const point of points) {
    if (point[0] >= x0 && point[0] <= x1 && point[1] >= y0 && point[1] <= y1) run.push(project(point));
    else if (run.length) {
      if (run.length > 1) runs.push(run);
      run = [];
    }
  }
  if (run.length > 1) runs.push(run);
  return runs.map((points) => points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join("")).join("");
}

export function patternOf(state, { xs, inside, lamp, zWater, zPaper }, project, phase = 0) {
  const lift = (lamp[2] - zPaper) / (lamp[2] - zWater);
  const onPaper = ([x, y]) => project([lamp[0] + (x - lamp[0]) * lift, lamp[1] + (y - lamp[1]) * lift, zPaper]);
  const half = state.spacing / 2;
  const nodal = [];
  for (const vertex of nodalVertices(state.spacing, state.lambdaMm)) {
    const a = Math.abs(vertex);
    const b = Math.sqrt(Math.max(half * half - a * a, 1e-6));
    const points = [];
    for (let index = 0; index <= 240; index++) {
      const t = -4 + (8 * index) / 240;
      points.push([xs + b * Math.sinh(t), Math.sign(vertex) * a * Math.cosh(t)]);
    }
    nodal.push(clippedRunsOf(points, inside, onPaper));
  }
  const crests = [];
  for (const sy of [-half, half]) {
    const reach = Math.max(...[inside[0], inside[2]].flatMap((x) => [inside[1], inside[3]].map((y) => Math.hypot(x - xs, y - sy))));
    for (let r = state.lambdaMm * (1 + (((phase / (2 * Math.PI)) % 1) + 1) % 1); r < reach; r += state.lambdaMm) {
      const points = [];
      const steps = Math.max(24, Math.round(r * 1.2));
      for (let index = 0; index <= steps; index++) {
        const a = (index / steps) * 2 * Math.PI;
        points.push([xs + r * Math.cos(a), sy + r * Math.sin(a)]);
      }
      crests.push(clippedRunsOf(points, inside, onPaper));
    }
  }
  return { nodal: nodal.join(""), crests: crests.join("") };
}
```

## examples/ripple-tank/ripple-tank-light.html

```

```

## examples/ripple-tank/ripple-tank.html

```

```

## examples/ripple-tank/ripple.css

```

```

## examples/spot-plate

```

```

## examples/spot-plate/art.tsx

```
import * as React from "react";
import { Dots, FaceText, Line, Solid } from "../../kit/react/draw";
import { BASE_ART, BOTTLE_ART, DECK_ART, LABEL, TRAY_ART } from "./geometry";
import { LiquidLive } from "./live";

function Lit({ id, children }: { id: string; children: React.ReactNode }) {
  return (
    <g data-part={id}>{children}</g>
  );
}

const BACK = (
  <g key="back">
    <Lit id="frame">
      <path className="iso-halo" d={BASE_ART.halo} />
      {BASE_ART.feet.map((parts, index) => (
        <g key={index}>
          {parts.map((paths, piece) => (
            <Solid key={piece} paths={paths} tone={piece ? "mid" : "lo"} crease="faint" />
          ))}
        </g>
      ))}
      <Solid paths={BASE_ART.slab} tone="mid" crease="faint" />
      <Line d={BASE_ART.groove} tone="lo" />
      <Line d={BASE_ART.inlay} tone="faint" />
      <Line d={BASE_ART.screwRings} tone="lo" />
      <Dots points={BASE_ART.screws} size={0.5} tone="mid" />
      <Solid className="ml-plate" paths={TRAY_ART.slab} tone="hi" crease="lo" />
      <Line d={TRAY_ART.groove} tone="lo" />
      <Line d={TRAY_ART.inlay} tone="faint" />
      {TRAY_ART.wells.map((well, index) => (
        <g key={index}>
          <path className="ml-floor" d={well.opening} />
          {well.wall ? <path className="ml-wall" d={well.wall} /> : null}
          <Line d={well.foot} tone="faint" />
          <Line d={well.opening} tone="mid" />
        </g>
      ))}
      <Dots points={TRAY_ART.counts} size={0.55} tone="mid" />
      <Line d={TRAY_ART.screwRings} tone="lo" />
      <Dots points={TRAY_ART.screws} size={0.5} tone="mid" />
    </Lit>
    <Lit id="apart">
      <Line d={TRAY_ART.minor} tone="lo" />
      <Line d={TRAY_ART.major} tone="mid" />
      <Line d={TRAY_ART.datum} tone="mid" />
      <Dots points={TRAY_ART.gapMarks} size={0.6} tone="hi" />
    </Lit>
  </g>
);

const DECK = (
  <g key="deck">
    <Lit id="frame">
      <Solid paths={DECK_ART.slab} tone="mid" crease="faint" />
      <Line d={DECK_ART.inlay} tone="faint" />
      <Line d={DECK_ART.screwRings} tone="lo" />
      <Dots points={DECK_ART.screws} size={0.45} tone="mid" />
      <Solid paths={BOTTLE_ART.coaster} tone="mid" crease="faint" />
      <Line d={BOTTLE_ART.coasterRing} tone="faint" />
      <Dots points={BOTTLE_ART.coasterDots} size={0.45} tone="mid" />
      <Solid className="ml-bottle" paths={BOTTLE_ART.body} tone="hi" crease="faint" />
      <Line d={BOTTLE_ART.foot} tone="faint" />
      <Line d={BOTTLE_ART.level} tone="lo" />
      <Line d={BOTTLE_ART.shine} tone="mid" />
      <Solid className="ml-bottle" paths={BOTTLE_ART.shoulder} tone="hi" crease="faint" />
      <Solid paths={BOTTLE_ART.neck} tone="mid" crease="faint" />
      <Solid paths={BOTTLE_ART.collar} tone="mid" crease="faint" />
      <Solid paths={BOTTLE_ART.cap} tone="hi" crease="lo" />
      <Line d={BOTTLE_ART.capKnurl} tone="lo" />
      <Line d={BOTTLE_ART.capRing} tone="faint" />
      <Solid paths={BOTTLE_ART.bulb} tone="mid" crease="faint" />
      <Line d={BOTTLE_ART.bulbShine} tone="lo" />
      <Line d={BOTTLE_ART.bulbRing} tone="faint" />
      <Dots points={[BOTTLE_ART.tip]} size={0.45} tone="mid" />
    </Lit>
    <Lit id="fan">
      <Line d={DECK_ART.guide} tone="faint" dashed />
      {DECK_ART.stops.map((paths, index) => (
        <Solid key={index} paths={paths} tone="mid" crease="faint" />
      ))}
      <Dots points={DECK_ART.stopTops} size={0.45} tone="hi" />
      <Solid paths={DECK_ART.bandBack} tone="mid" crease="faint" />
      <FaceText transform={DECK_ART.label} size={4.2} tone="lo">
        merge
      </FaceText>
      <Solid paths={DECK_ART.washer} tone="lo" crease="faint" />
    </Lit>
  </g>
);

const FRONT = (
  <Lit key="front" id="fan">
    <Solid paths={DECK_ART.bandFront} tone="mid" crease="faint" />
    <Line d={DECK_ART.bandLine} tone="faint" />
    <Line d={DECK_ART.minor} tone="lo" />
    <Line d={DECK_ART.major} tone="mid" />
    <Dots points={DECK_ART.gapDots} size={0.55} tone="hi" />
    <Dots points={[...DECK_ART.ends, DECK_ART.seam]} size={0.45} tone="mid" />
    <Solid paths={DECK_ART.cap} tone="mid" crease="faint" />
    <Solid paths={DECK_ART.nut} tone="hi" crease="lo" />
    <Line d={DECK_ART.nutKnurl} tone="lo" />
    <Line d={DECK_ART.nutRing} tone="faint" />
    <Dots points={[DECK_ART.pin]} size={0.6} tone="hi" />
  </Lit>
);

export function LiquidArt() {
  return <LiquidLive label={LABEL} back={BACK} deck={DECK} front={FRONT} />;
}
```

## examples/spot-plate/drops.ts

```ts
import { type Flat, iso, pathOf, type Plane } from "../../kit/iso-kit";
import { BIG_Y, CLIMB, FLOOR, GAPS, LIGHT, P, RA, RB, THICK, VIEW, fieldOf, rowX, rowY, spanOf, type Field } from "./rig";

const STEP = 2;
const FINE = 0.5;
const FINE_HALF = 6;
const NUDGE = -0.05;
const KISS = 0.75;
const WINDOW = 4;
const BEND = 0.3;
const BEVEL = 2.2;
const TAPER = 10;
const SHADES = 4;
const STRIP = 8;
const SHINE_IN = 3.4;
const SHINE_FACING = 0.82;
const FADE_SHARE = 0.3;
const FADE_MOST = 9;
const FADE_STEPS = 6;

interface Grid {
  values: Float32Array;
  us: Float64Array;
  vs: Float64Array;
}

function ticksOf(centre: number, before: number, after: number) {
  const ticks: number[] = [];
  for (let k = -Math.ceil(before / STEP); k <= Math.ceil(after / STEP); k++) {
    if (Math.abs(k * STEP) > FINE_HALF) ticks.push(centre + k * STEP);
  }
  const fine = Math.round(FINE_HALF / FINE);
  for (let k = -fine; k <= fine; k++) ticks.push(centre + k * FINE);
  return Float64Array.from(ticks.sort((a, b) => a - b));
}

function sample(field: Field, gap: number): Grid {
  const middle = RA + gap / 2;
  const us = ticksOf(middle, middle + RA + 6, spanOf(gap) + RB + 6 - middle);
  const vs = ticksOf(0, RA + 6, RA + 6);
  const nx = us.length;
  const ny = vs.length;
  const values = new Float32Array(nx * ny);
  for (let j = 0; j < ny; j++) {
    const mirror = ny - 1 - j;
    if (mirror < j) {
      values.copyWithin(j * nx, mirror * nx, mirror * nx + nx);
      continue;
    }
    const v = vs[j]!;
    for (let i = 0; i < nx; i++) values[j * nx + i] = field(us[i]!, v);
  }
  return { values, us, vs };
}

const SEGMENTS: Record<number, [number, number][]> = {
  1: [[3, 2]],
  2: [[2, 1]],
  3: [[3, 1]],
  4: [[0, 1]],
  6: [[0, 2]],
  7: [[3, 0]],
  8: [[3, 0]],
  9: [[0, 2]],
  11: [[0, 1]],
  12: [[3, 1]],
  13: [[2, 1]],
  14: [[3, 2]],
};

function rings({ values, us, vs }: Grid, level: number): Plane[][] {
  const nx = us.length;
  const ny = vs.length;
  const at = (i: number, j: number) => values[j * nx + i]!;
  const links = new Map<number, number[]>();
  const points = new Map<number, Plane>();
  const pointOf = (id: number) => {
    if (points.has(id)) return;
    const cell = id >> 1;
    const i = cell % nx;
    const j = (cell - i) / nx;
    const down = id & 1;
    const a = at(i, j);
    const b = down ? at(i, j + 1) : at(i + 1, j);
    const t = Math.min(Math.max((level - a) / (b - a), 0), 1);
    points.set(id, down ? [us[i]!, vs[j]! + (vs[j + 1]! - vs[j]!) * t] : [us[i]! + (us[i + 1]! - us[i]!) * t, vs[j]!]);
  };
  const link = (a: number, b: number) => {
    pointOf(a);
    pointOf(b);
    (links.get(a) ?? links.set(a, []).get(a)!).push(b);
    (links.get(b) ?? links.set(b, []).get(b)!).push(a);
  };
  for (let j = 0; j < ny - 1; j++) {
    for (let i = 0; i < nx - 1; i++) {
      const tl = at(i, j);
      const tr = at(i + 1, j);
      const br = at(i + 1, j + 1);
      const bl = at(i, j + 1);
      const code = (tl < level ? 8 : 0) | (tr < level ? 4 : 0) | (br < level ? 2 : 0) | (bl < level ? 1 : 0);
      if (code === 0 || code === 15) continue;
      const edges = [(j * nx + i) * 2, ((j * nx + i + 1) * 2) | 1, ((j + 1) * nx + i) * 2, ((j * nx + i) * 2) | 1];
      let pairs = SEGMENTS[code];
      if (!pairs) {
        const centre = (tl + tr + br + bl) / 4 < level;
        if (code === 5) pairs = centre ? [[3, 0], [2, 1]] : [[0, 1], [3, 2]];
        else pairs = centre ? [[0, 1], [3, 2]] : [[3, 0], [2, 1]];
      }
      for (const [a, b] of pairs) link(edges[a]!, edges[b]!);
    }
  }
  const seen = new Set<number>();
  const found: Plane[][] = [];
  for (const start of links.keys()) {
    if (seen.has(start)) continue;
    const ring: Plane[] = [];
    let previous = -1;
    let current = start;
    while (!seen.has(current)) {
      seen.add(current);
      ring.push(points.get(current)!);
      const next = links.get(current)!.find((candidate) => candidate !== previous && !seen.has(candidate));
      if (next === undefined) break;
      previous = current;
      current = next;
    }
    if (ring.length > 3) found.push(ring);
  }
  return found;
}

function simplify(points: Plane[], tolerance: number): Plane[] {
  if (points.length < 3) return points;
  const first = points[0]!;
  const last = points[points.length - 1]!;
  const dx = last[0] - first[0];
  const dy = last[1] - first[1];
  const length = Math.hypot(dx, dy) || 1;
  let worst = 0;
  let index = 0;
  for (let step = 1; step < points.length - 1; step++) {
    const point = points[step]!;
    const off = Math.abs((point[0] - first[0]) * dy - (point[1] - first[1]) * dx) / length;
    if (off > worst) {
      worst = off;
      index = step;
    }
  }
  if (worst <= tolerance) return [first, last];
  return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)];
}

function simplifyRing(ring: Plane[], tolerance: number): Plane[] {
  let far = 0;
  let best = 0;
  for (let index = 1; index < ring.length; index++) {
    const distance = Math.hypot(ring[index]![0] - ring[0]![0], ring[index]![1] - ring[0]![1]);
    if (distance > best) {
      best = distance;
      far = index;
    }
  }
  const one = simplify(ring.slice(0, far + 1), tolerance);
  const two = simplify([...ring.slice(far), ring[0]!], tolerance);
  return [...one.slice(0, -1), ...two.slice(0, -1)];
}

function signedArea(points: Plane[] | Flat[]) {
  let area = 0;
  for (let index = 0; index < points.length; index++) {
    const [ax, ay] = points[index]!;
    const [bx, by] = points[(index + 1) % points.length]!;
    area += ax * by - bx * ay;
  }
  return area / 2;
}

const oriented = (points: Flat[]) => (signedArea(points) < 0 ? [...points].reverse() : points);

function runsOf(flags: boolean[], want: boolean) {
  const count = flags.length;
  const runs: number[][] = [];
  const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
  if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
  let run: number[] | null = null;
  for (let step = 0; step < count; step++) {
    const index = (start + step) % count;
    if (flags[index] === want) {
      if (!run) run = [index];
      run.push((index + 1) % count);
    } else if (run) {
      runs.push(run);
      run = null;
    }
  }
  if (run) runs.push(run);
  return runs;
}

export interface GlassShape {
  fill: string;
  shades: { d: string; shade: number }[];
  top: string;
  crease: string;
  bevel: string;
  outline: string;
  shine: string;
  depth: number;
}

const ease = (t: number) => t * t * (3 - 2 * t);

type Hidden = (x: number, y: number, z: number, edge: number) => boolean;

function hiddenBy(ring: Plane[], top: number): Hidden {
  const count = ring.length;
  const xs = new Float64Array(count);
  const ys = new Float64Array(count);
  for (let index = 0; index < count; index++) {
    xs[index] = ring[index]![0];
    ys[index] = ring[index]![1];
  }
  const inside = (x: number, y: number) => {
    let hit = false;
    for (let index = 0, previous = count - 1; index < count; previous = index++) {
      const ay = ys[index]!;
      const by = ys[previous]!;
      if (ay > y !== by > y && x < ((xs[previous]! - xs[index]!) * (y - ay)) / (by - ay) + xs[index]!) hit = !hit;
    }
    return hit;
  };
  return (x, y, z, edge) => {
    const reach = (top - z) / CLIMB;
    if (reach <= 0) return false;
    if (inside(x + VIEW[0] * 0.05, y + VIEW[1] * 0.05)) return true;
    const fx = x + VIEW[0] * reach;
    const fy = y + VIEW[1] * reach;
    const left = Math.min(x, fx);
    const right = Math.max(x, fx);
    const low = Math.min(y, fy);
    const high = Math.max(y, fy);
    for (let index = 0; index < count; index++) {
      if (index === edge || (edge < 0 && (index === -edge - 1 || (index + 1) % count === -edge - 1))) continue;
      const next = index + 1 === count ? 0 : index + 1;
      const ax = xs[index]!;
      const ay = ys[index]!;
      const bx = xs[next]!;
      const by = ys[next]!;
      if ((ax < left && bx < left) || (ax > right && bx > right) || (ay < low && by < low) || (ay > high && by > high)) continue;
      const ex = bx - ax;
      const ey = by - ay;
      const cross = VIEW[0] * ey - VIEW[1] * ex;
      if (Math.abs(cross) < 1e-12) continue;
      const ox = ax - x;
      const oy = ay - y;
      const t = (ox * ey - oy * ex) / cross;
      const u = (ox * VIEW[1] - oy * VIEW[0]) / cross;
      if (t > 0.05 && t < reach && u >= 0 && u <= 1) return true;
    }
    return false;
  };
}

function visibleRuns(ring: Plane[], run: number[], z: number, hidden: Hidden): Flat[][] {
  const runs: Flat[][] = [];
  let line: Flat[] = [];
  const shown = run.map((index) => !hidden(ring[index]![0], ring[index]![1], z, -index - 1));
  const lift = ([x, y]: Plane) => iso([x, y, z], P);
  const cut = (edge: number, fromShown: boolean): Plane => {
    const a = ring[edge]!;
    const b = ring[(edge + 1) % ring.length]!;
    let low = 0;
    let high = 1;
    for (let round = 0; round < 14; round++) {
      const mid = (low + high) / 2;
      const seen = !hidden(a[0] + (b[0] - a[0]) * mid, a[1] + (b[1] - a[1]) * mid, z, edge);
      if (seen === fromShown) low = mid;
      else high = mid;
    }
    const t = (low + high) / 2;
    return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
  };
  for (let index = 0; index < run.length; index++) {
    const point = ring[run[index]!]!;
    if (index > 0 && shown[index] !== shown[index - 1]) {
      const edge = cut(run[index - 1]!, shown[index - 1]!);
      if (shown[index - 1]) {
        line.push(lift(edge));
        if (line.length > 1) runs.push(line);
        line = [];
      } else {
        line = [lift(edge)];
      }
    }
    if (shown[index]) line.push(lift(point));
  }
  if (line.length > 1) runs.push(line);
  return runs;
}

function solidOf(ring: Plane[], z: number, height: number, occlude: boolean): GlassShape {
  const count = ring.length;
  const counter = signedArea(ring) > 0;
  const normals = ring.map(([ax, ay], index) => {
    const [bx, by] = ring[(index + 1) % count]!;
    const dx = bx - ax;
    const dy = by - ay;
    const length = Math.hypot(dx, dy) || 1;
    return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane;
  });
  const facing = normals.map(([nx, ny]) => nx * VIEW[0] + ny * VIEW[1] > 1e-9);
  const top = ring.map(([x, y]) => iso([x, y, z + height], P));
  const bottom = ring.map(([x, y]) => iso([x, y, z], P));
  const hidden = occlude ? hiddenBy(ring, z + height) : null;

  const strips: { depth: number; shade: number; d: string }[] = [];
  let open: { from: number; to: number; shade: number } | null = null;
  const close = () => {
    if (!open) return;
    const upper: Flat[] = [];
    const lower: Flat[] = [];
    let depth = 0;
    for (let index = open.from; index <= open.to + 1; index++) {
      const point = index % count;
      upper.push(top[point]!);
      lower.push(bottom[point]!);
      depth += ring[point]![0] * VIEW[0] + ring[point]![1] * VIEW[1];
    }
    strips.push({ depth: depth / upper.length, shade: open.shade, d: pathOf(oriented([...upper, ...lower.reverse()]), true) });
    open = null;
  };
  for (let index = 0; index < count; index++) {
    if (!facing[index]) {
      close();
      continue;
    }
    const [nx, ny] = normals[index]!;
    const lit = (nx * LIGHT[0] + ny * LIGHT[1] + 1) / 2;
    const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES)));
    if (open && open.shade === shade && index - open.from < STRIP) open.to = index;
    else {
      close();
      open = { from: index, to: index, shade };
    }
  }
  close();
  strips.sort((a, b) => a.depth - b.depth);
  const shades: { d: string; shade: number }[] = [];
  for (const strip of strips) {
    const last = shades[shades.length - 1];
    if (last && last.shade === strip.shade) last.d += strip.d;
    else shades.push({ d: strip.d, shade: strip.shade });
  }

  const inward = (index: number, by: number): Plane => {
    const [ax, ay] = normals[(index + count - 1) % count]!;
    const [bx, by2] = normals[index]!;
    const mx = ax + bx;
    const my = ay + by2;
    const length = Math.hypot(mx, my) || 1;
    const [x, y] = ring[index]!;
    return [x - (mx / length) * by, y - (my / length) * by];
  };

  const crease: string[] = [];
  const outline: string[] = [];
  const bevel: string[] = [];
  for (const run of runsOf(facing, false)) outline.push(pathOf(run.map((index) => top[index]!)));
  for (const run of runsOf(facing, true)) {
    crease.push(pathOf(run.map((index) => top[index]!)));
    if (hidden) for (const seen of visibleRuns(ring, run, z, hidden)) outline.push(pathOf(seen));
    else outline.push(pathOf(run.map((index) => bottom[index]!)));
    const along = [0];
    for (let step = 1; step < run.length; step++) {
      const [ax, ay] = ring[run[step - 1]!]!;
      const [bx, by] = ring[run[step]!]!;
      along.push(along[step - 1]! + Math.hypot(bx - ax, by - ay));
    }
    const total = along[along.length - 1]!;
    bevel.push(
      pathOf(
        run.map((index, step) => {
          const near = Math.min(along[step]!, total - along[step]!);
          const [x, y] = inward(index, BEVEL * ease(Math.min(1, near / TAPER)));
          return iso([x, y, z + height], P);
        }),
      ),
    );
  }
  for (let index = 0; index < count; index++) {
    if (facing[(index + count - 1) % count] === facing[index]) continue;
    const [x, y] = ring[index]!;
    let from = z;
    if (hidden && hidden(x, y, z, -index - 1)) {
      let low = z;
      let high = z + height;
      for (let round = 0; round < 12; round++) {
        const mid = (low + high) / 2;
        if (hidden(x, y, mid, -index - 1)) low = mid;
        else high = mid;
      }
      from = high;
    }
    if (z + height - from > 0.05) outline.push(pathOf([top[index]!, iso([x, y, from], P)]));
  }

  const toward = normals.map(([nx, ny], index) => {
    const [px, py] = normals[(index + count - 1) % count]!;
    return ((nx + px) * LIGHT[0] + (ny + py) * LIGHT[1]) / 2 > SHINE_FACING;
  });
  const shine = runsOf(toward, true)
    .filter((run) => run.length > 3)
    .map((run) => pathOf(run.slice(1, -1).map((index) => iso([...inward(index, SHINE_IN), z + height] as [number, number, number], P))))
    .join("");

  const cx = ring.reduce((sum, [x]) => sum + x, 0) / count;
  const cy = ring.reduce((sum, [, y]) => sum + y, 0) / count;
  return {
    fill: [pathOf(oriented(top), true), pathOf(oriented(bottom), true)].join(""),
    shades,
    top: pathOf(oriented(top), true),
    crease: crease.join(""),
    bevel: bevel.join(""),
    outline: outline.join(""),
    shine,
    depth: cx * VIEW[0] + cy * VIEW[1],
  };
}

export interface Glint {
  d: string;
  alpha: number;
}

export interface RowShape {
  row: number;
  gap: number;
  joined: boolean;
  glass: GlassShape[];
  neck: Glint[];
}

const atTop = (points: Plane[]) => points.map(([x, y]) => iso([x, y, FLOOR + THICK], P));

function concaveRuns(ring: Plane[]): number[][] {
  const count = ring.length;
  const along = [0];
  for (let index = 0; index < count; index++) {
    const [ax, ay] = ring[index]!;
    const [bx, by] = ring[(index + 1) % count]!;
    along.push(along[index]! + Math.hypot(bx - ax, by - ay));
  }
  const total = along[count]!;
  const pointAt = (distance: number): Plane => {
    const s = ((distance % total) + total) % total;
    let low = 0;
    let high = count;
    while (high - low > 1) {
      const mid = (low + high) >> 1;
      if (along[mid]! <= s) low = mid;
      else high = mid;
    }
    const [ax, ay] = ring[low]!;
    const [bx, by] = ring[(low + 1) % count]!;
    const t = (s - along[low]!) / Math.max(along[low + 1]! - along[low]!, 1e-9);
    return [ax + (bx - ax) * t, ay + (by - ay) * t];
  };
  const turn = signedArea(ring) > 0 ? 1 : -1;
  const flags = ring.map(([x, y], index) => {
    const [ax, ay] = pointAt(along[index]! - WINDOW);
    const [bx, by] = pointAt(along[index]! + WINDOW);
    return turn * ((x - ax) * (by - y) - (y - ay) * (bx - x)) < -BEND;
  });
  return runsOf(flags, true).filter((run) => run.length > 2);
}

function glintsOf(points: Flat[]): Glint[] {
  const along = [0];
  for (let index = 1; index < points.length; index++) {
    along.push(along[index - 1]! + Math.hypot(points[index]![0] - points[index - 1]![0], points[index]![1] - points[index - 1]![1]));
  }
  const total = along[along.length - 1]!;
  if (total < 0.5) return [];
  const cut = (from: number, to: number): Flat[] => {
    const out: Flat[] = [];
    const place = (distance: number) => {
      let index = 1;
      while (index < along.length - 1 && along[index]! < distance) index++;
      const t = (distance - along[index - 1]!) / Math.max(along[index]! - along[index - 1]!, 1e-9);
      const [ax, ay] = points[index - 1]!;
      const [bx, by] = points[index]!;
      return [ax + (bx - ax) * t, ay + (by - ay) * t] as Flat;
    };
    out.push(place(from));
    for (let index = 0; index < points.length; index++) if (along[index]! > from && along[index]! < to) out.push(points[index]!);
    out.push(place(to));
    return out;
  };
  const fade = Math.min(total * FADE_SHARE, FADE_MOST);
  const glints: Glint[] = [];
  let before = 0;
  for (let layer = 0; layer < FADE_STEPS; layer++) {
    const now = layer === FADE_STEPS - 1 ? 1 : ease((layer + 1) / FADE_STEPS);
    const alpha = Math.round((1 - (1 - now) / (1 - before)) * 100) / 100;
    before = now;
    const inset = (fade * layer) / (FADE_STEPS - 1);
    glints.push({ d: pathOf(cut(inset, total - inset)), alpha });
  }
  return glints;
}

const cache = new Map<number, RowShape>();
const CACHE_MOST = 420;

export const keyOf = (merge: number) => Math.round(merge * 4);

export function rowOf(row: number, merge: number): RowShape {
  const gap = GAPS[row]!;
  const plain = gap >= 2 * merge;
  const key = row * 1000 + (plain ? 999 : keyOf(merge));
  const known = cache.get(key);
  if (known) return known;
  const exact = plain ? 0 : keyOf(merge) / 4;
  const x0 = rowX(row);
  const y0 = rowY(row) + BIG_Y;
  const toWorld = ([u, v]: Plane): Plane => [x0 + v, y0 + u];
  const field = fieldOf(gap, exact);
  const kiss = !plain && keyOf(merge) === gap * 4;
  const level = kiss ? field(RA + gap / 2, KISS) : NUDGE;
  const found = rings(sample(field, gap), level).map((ring) => simplifyRing(ring, 0.06));
  const joined = gap < exact && found.length === 1;
  const whole = found.length === 1;
  const glass = found.map((local) => solidOf(local.map(toWorld), FLOOR, THICK, whole)).sort((a, b) => a.depth - b.depth);
  const neck = joined ? concaveRuns(found[0]!.map(toWorld)).flatMap((run) => glintsOf(atTop(run.map((index) => toWorld(found[0]![index]!))))) : [];
  const shape: RowShape = { row, gap, joined, glass, neck };
  if (cache.size >= CACHE_MOST) cache.delete(cache.keys().next().value!);
  cache.set(key, shape);
  return shape;
}

export const rowsOf = (merge: number) => GAPS.map((_, row) => rowOf(row, merge));
```

## examples/spot-plate/fan.ts

```ts
import { extrude, iso, pathOf, type Plane } from "../../kit/iso-kit";
import { BLADE_LONG, BLADE_Z, DECK_H, GAPS, LEAD_H, LEAD_LONG, P, PIVOT, ROWS, angleOf, thicknessOf } from "./rig";

const RADIANS = Math.PI / 180;
const BUTT = 7.2;
const TIP = 4.2;
const LEAD_TIP = 3.2;
const ARC = 10;

export interface BladePose {
  fill: string;
  shades: string[];
  top: string;
  crease: string;
  outline: string;
  line: string;
}

export interface FanPose {
  blades: BladePose[];
  halo: string;
  out: boolean[];
}

function outlineOf(length: number, tip: number): Plane[] {
  const out: Plane[] = [];
  for (let step = 0; step <= ARC; step++) {
    const angle = -90 + (180 * step) / ARC;
    out.push([length + tip * Math.cos(angle * RADIANS), tip * Math.sin(angle * RADIANS)]);
  }
  for (let step = 0; step <= ARC; step++) {
    const angle = 90 + (180 * step) / ARC;
    out.push([BUTT * Math.cos(angle * RADIANS), BUTT * Math.sin(angle * RADIANS)]);
  }
  return out;
}

const BLADE = outlineOf(BLADE_LONG, TIP);
const LEAD = outlineOf(LEAD_LONG, LEAD_TIP);

function place(shape: Plane[], degrees: number): Plane[] {
  const a = degrees * RADIANS;
  const dx = Math.sin(a);
  const dy = -Math.cos(a);
  const nx = Math.cos(a);
  const ny = Math.sin(a);
  return shape.map(([u, v]) => [PIVOT[0] + u * dx + v * nx, PIVOT[1] + u * dy + v * ny]);
}

function along(degrees: number, from: number, to: number, z: number) {
  const a = degrees * RADIANS;
  const dx = Math.sin(a);
  const dy = -Math.cos(a);
  return pathOf([iso([PIVOT[0] + from * dx, PIVOT[1] + from * dy, z], P), iso([PIVOT[0] + to * dx, PIVOT[1] + to * dy, z], P)]);
}

function bladeAt(shape: Plane[], degrees: number, z: number, height: number, length: number): BladePose {
  const ring = place(shape, degrees);
  const solid = extrude(ring, z, height, P, { convex: true });
  return {
    fill: solid.fill,
    shades: solid.shades ?? [],
    top: solid.top,
    crease: solid.crease,
    outline: solid.outline,
    line: along(degrees, 13, length - 6, z + height),
  };
}

export function fanOf(merge: number): FanPose {
  const lead = angleOf(merge);
  const blades: BladePose[] = [];
  const halo: string[] = [];
  const out: boolean[] = [];
  GAPS.forEach((gap, index) => {
    const angle = Math.min(lead, angleOf(gap));
    out.push(gap < merge);
    blades.push(bladeAt(BLADE, angle, BLADE_Z[index]!, thicknessOf(gap), BLADE_LONG));
    halo.push(pathOf(place(BLADE, angle).map(([x, y]) => iso([x, y, DECK_H], P)), true));
  });
  blades.push(bladeAt(LEAD, lead, BLADE_Z[ROWS]!, LEAD_H, LEAD_LONG));
  halo.push(pathOf(place(LEAD, lead).map(([x, y]) => iso([x, y, DECK_H], P)), true));
  return { blades, halo: halo.join(""), out };
}
```

## examples/spot-plate/figure.tsx

```
import { Plate } from "../../kit/react/draw";
import { LiquidArt } from "./art";
import { GAPS, MERGE, RADII, bladesOutOf, joinedListOf, readoutOf } from "./rig";
import "./spot-plate.css";

const [LARGE, SMALL] = RADII;

export function SpotPlateFigure() {
  return (
    <Plate
      fig="Fig 7"
      title="Spot plate"
      hint="Slide across to open the fan"
      readout={readoutOf(MERGE)}
      keys={[
        { mark: "raised", label: `Joined: one piece of glass, R${LARGE} + R${SMALL}` },
        { mark: "edge", label: "The neck, where a pair runs together" },
        { mark: "lit", label: "The merge blade" },
      ]}
      caption={`A spot plate with six channels, each holding a pair of glass drops at gaps from ${GAPS[0]} to ${GAPS[GAPS.length - 1]} px. Each pair is drawn from one distance field, so a pair closer than merge runs together into one piece of glass. The fan sets merge: each feeler blade stops at its own gap. At ${MERGE} px the ${joinedListOf(MERGE)} pairs have joined, so ${bladesOutOf(MERGE)}.`}
    >
      <LiquidArt />
    </Plate>
  );
}
```

## examples/spot-plate/geometry.ts

```ts
import { circleRing, extrude, insetPlan, onTop, pathOf, type Plan, type Plane, planOutline, type Point3, ringOf, type SolidPaths, topMatrix } from "../../kit/iso-kit";
import {
  BASE,
  BASE_H,
  BOTTLE,
  DATUM_Y,
  DECK,
  DECK_H,
  FOOT_H,
  GAPS,
  MERGE_MOST,
  P,
  PIVOT,
  SCALE_R,
  STACK_TOP,
  STOP_R,
  SWEEP,
  TRAY,
  TRAY_H,
  FLOOR,
  VIEW,
  WASHER_H,
  WELL_D,
  WELL_HALF,
  WELLS,
  BORDER,
  angleOf,
  at,
  rowX,
  rowY,
  ROW_RIM,
} from "./rig";

const RADIANS = Math.PI / 180;
const VIEW_ANGLE = Math.atan2(VIEW[1], VIEW[0]);

const line = (points: Point3[]) => pathOf(points.map(at));
const lines = (runs: Point3[][]) => runs.map(line).join("");

function clean(ring: Plane[]): Plane[] {
  return ring.filter(([x, y], index) => {
    const [nx, ny] = ring[(index + 1) % ring.length]!;
    return Math.hypot(nx - x, ny - y) > 1e-6;
  });
}

const solid = (plan: Plan, z: number, height: number, steps = 6, bevel = 0): SolidPaths =>
  extrude(clean(ringOf(plan, steps)), z, height, P, { convex: true, bevel });

const cyl = (x: number, y: number, r: number, z: number, h: number, steps = 28): SolidPaths => extrude(circleRing(x, y, r, steps), z, h, P);

const ring = (x: number, y: number, r: number, z: number, steps = 24) => onTop(circleRing(x, y, r, steps), z, P, true);

function knurl(x: number, y: number, r: number, z0: number, z1: number, count = 9) {
  const runs: Point3[][] = [];
  for (let index = 0; index < count; index++) {
    const angle = VIEW_ANGLE - Math.PI * 0.42 + (Math.PI * 0.84 * index) / (count - 1);
    const px = x + r * Math.cos(angle);
    const py = y + r * Math.sin(angle);
    runs.push([
      [px, py, z0 + 0.4],
      [px, py, z1 - 0.4],
    ]);
  }
  return lines(runs);
}

function normalsOf(points: Plane[]) {
  const count = points.length;
  let area = 0;
  for (let index = 0; index < count; index++) {
    const [ax, ay] = points[index]!;
    const [bx, by] = points[(index + 1) % count]!;
    area += ax * by - bx * ay;
  }
  const counter = area > 0;
  return points.map(([ax, ay], index) => {
    const [bx, by] = points[(index + 1) % count]!;
    const dx = bx - ax;
    const dy = by - ay;
    const length = Math.hypot(dx, dy) || 1;
    return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane;
  });
}

function runOf(points: Plane[], want: boolean) {
  const count = points.length;
  const facing = normalsOf(points).map(([nx, ny]) => nx * VIEW[0] + ny * VIEW[1] > 1e-9);
  const start = facing.findIndex((flag, index) => flag === want && facing[(index + count - 1) % count] !== want);
  if (start < 0) return [] as Plane[];
  const run: Plane[] = [points[start]!];
  for (let step = 0; step < count && facing[(start + step) % count] === want; step++) run.push(points[(start + step + 1) % count]!);
  return run;
}

const visibleRun = (plan: Plan, z: number, steps = 8) => onTop(runOf(clean(ringOf(plan, steps)), true), z, P);

const corners = ({ x, y, w, d }: Plan, inset: number): Plane[] => [
  [x + inset, y + inset],
  [x + w - inset, y + inset],
  [x + w - inset, y + d - inset],
  [x + inset, y + d - inset],
];

const FEET = corners(BASE, 18);
const BASE_SCREWS = corners(BASE, 6);

export const BASE_ART = {
  halo: solid(insetPlan(BASE, 6), -BASE_H - FOOT_H, 0.4, 8).fill,
  feet: FEET.map(([x, y]) => [cyl(x, y, 3.6, -BASE_H - FOOT_H, FOOT_H - 1.2, 24), cyl(x, y, 4.8, -BASE_H - 1.2, 1.2, 28)]),
  slab: solid(BASE, -BASE_H, BASE_H, 10, 2.2),
  groove: visibleRun(BASE, -BASE_H * 0.45, 10),
  inlay: planOutline(insetPlan(BASE, 4), 0, P, 8),
  screws: BASE_SCREWS.map(([x, y]) => at([x, y, 0])),
  screwRings: BASE_SCREWS.map(([x, y]) => ring(x, y, 1.6, 0, 18)).join(""),
};

function wellOf(plan: Plan) {
  const points = clean(ringOf(plan, 12));
  const back = runOf(points, false);
  const wall = back.length > 1 ? pathOf([...back.map(([x, y]) => at([x, y, TRAY_H])), ...[...back].reverse().map(([x, y]) => at([x, y, FLOOR]))], true) : "";
  return {
    opening: onTop(points, TRAY_H, P, true),
    wall,
    foot: onTop(back, FLOOR, P),
  };
}

const RULE_X = WELL_HALF + 2.2;
const RULE_MINOR = 2.2;
const RULE_MAJOR = 4.6;
const RULE_REACH = MERGE_MOST;

function rulerOf(row: number) {
  const x0 = rowX(row) + RULE_X;
  const minor: Point3[][] = [];
  const major: Point3[][] = [];
  for (let step = 0; step <= RULE_REACH; step += 2) {
    const y = rowY(row) + DATUM_Y + step;
    (step % 8 === 0 ? major : minor).push([
      [x0, y, TRAY_H],
      [x0 + (step % 8 === 0 ? RULE_MAJOR : RULE_MINOR), y, TRAY_H],
    ]);
  }
  return { minor, major };
}

const RULERS = GAPS.map((_, row) => rulerOf(row));

const countY = (row: number) => rowY(row) + WELL_D + (row < 3 ? ROW_RIM : BORDER) / 2;

const TRAY_SCREWS = corners(TRAY, 7);

export const TRAY_ART = {
  screws: TRAY_SCREWS.map(([x, y]) => at([x, y, TRAY_H])),
  screwRings: TRAY_SCREWS.map(([x, y]) => ring(x, y, 1.7, TRAY_H, 18)).join(""),
  slab: solid(TRAY, 0, TRAY_H, 10, 2.4),
  groove: visibleRun(TRAY, TRAY_H * 0.42, 10),
  inlay: planOutline(insetPlan(TRAY, 4.2), TRAY_H, P, 10),
  wells: WELLS.map(wellOf),
  minor: lines(RULERS.flatMap((ruler) => ruler.minor)),
  major: lines(RULERS.flatMap((ruler) => ruler.major)),
  datum: lines(
    GAPS.map((_, row) => [
      [rowX(row) - WELL_HALF - 3.4, rowY(row) + DATUM_Y, TRAY_H],
      [rowX(row) - WELL_HALF - 0.8, rowY(row) + DATUM_Y, TRAY_H],
    ]),
  ),
  gapMarks: GAPS.map((gap, row) => at([rowX(row) + RULE_X + RULE_MAJOR + 2.4, rowY(row) + DATUM_Y + gap, TRAY_H])),
  counts: GAPS.flatMap((_, row) =>
    Array.from({ length: row + 1 }, (__, dot) => at([rowX(row) + (dot - row / 2) * 3.2, countY(row), TRAY_H])),
  ),
};

function sector(r0: number, r1: number, from: number, to: number, steps = 40): Plane[] {
  const out: Plane[] = [];
  const point = (r: number, degrees: number): Plane => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS)];
  for (let step = 0; step <= steps; step++) out.push(point(r1, from + ((to - from) * step) / steps));
  for (let step = steps; step >= 0; step--) out.push(point(r0, from + ((to - from) * step) / steps));
  return out;
}

const polar = (r: number, degrees: number, z: number): Point3 => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS), z];

const BAND_IN = SCALE_R - 2;
const BAND_OUT = SCALE_R + 10;
const BAND_FROM = -7;
const BAND_TO = SWEEP + 7;
const BAND_SPLIT = (Math.atan2(VIEW[1], VIEW[0]) * 180) / Math.PI;
const BAND_TOP = STACK_TOP;

function scaleTicks() {
  const minor: Point3[][] = [];
  const major: Point3[][] = [];
  for (let merge = 0; merge <= MERGE_MOST; merge += 2) {
    const angle = angleOf(merge);
    const long = merge % 8 === 0;
    (long ? major : minor).push([polar(BAND_IN + 1.2, angle, BAND_TOP), polar(BAND_IN + (long ? 6 : 3.6), angle, BAND_TOP)]);
  }
  return { minor: lines(minor), major: lines(major) };
}

const TICKS = scaleTicks();

const DECK_SCREWS = corners(DECK, 6);

const STOPS = GAPS.map((gap) => polar(STOP_R, angleOf(gap), DECK_H));

export const DECK_ART = {
  slab: solid(DECK, 0, DECK_H, 8, 1.6),
  inlay: planOutline(insetPlan(DECK, 3.4), DECK_H, P, 8),
  screws: DECK_SCREWS.map(([x, y]) => at([x, y, DECK_H])),
  screwRings: DECK_SCREWS.map(([x, y]) => ring(x, y, 1.5, DECK_H, 18)).join(""),
  guide: onTop(
    Array.from({ length: 61 }, (_, step) => {
      const [x, y] = polar(STOP_R + 3, (SWEEP * step) / 60, DECK_H);
      return [x, y] as Plane;
    }),
    DECK_H,
    P,
  ),
  stops: STOPS.map(([x, y]) => cyl(x, y, 1.3, DECK_H, 1.8, 16)),
  stopTops: STOPS.map(([x, y]) => at([x, y, DECK_H + 1.8])),
  bandBack: extrude(sector(BAND_IN, BAND_OUT, BAND_FROM, BAND_SPLIT, 24), DECK_H, BAND_TOP - DECK_H, P, { convex: false }),
  bandFront: extrude(sector(BAND_IN, BAND_OUT, BAND_SPLIT, BAND_TO, 64), DECK_H, BAND_TOP - DECK_H, P, { convex: false }),
  seam: at(polar((BAND_IN + BAND_OUT) / 2, BAND_SPLIT, BAND_TOP)),
  bandLine: onTop(sector(BAND_OUT - 1.6, BAND_OUT - 1.6, BAND_FROM + 1.2, BAND_TO - 1.2, 96).slice(0, 97), BAND_TOP, P),
  minor: TICKS.minor,
  major: TICKS.major,
  gapDots: GAPS.map((gap) => at(polar(BAND_OUT - 3.6, angleOf(gap), BAND_TOP))),
  ends: [0, SWEEP].map((angle) => at(polar(BAND_OUT - 3.6, angle, BAND_TOP))),
  label: topMatrix([PIVOT[0] - 13, PIVOT[1] + 150], DECK_H, P, "x"),
  washer: cyl(PIVOT[0], PIVOT[1], 9.4, DECK_H, WASHER_H, 32),
  cap: cyl(PIVOT[0], PIVOT[1], 8.2, STACK_TOP, 0.8, 32),
  nut: cyl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, 4.2, 28),
  nutKnurl: knurl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, STACK_TOP + 5, 13),
  nutRing: ring(PIVOT[0], PIVOT[1], 3.2, STACK_TOP + 5, 20),
  pin: at([PIVOT[0], PIVOT[1], STACK_TOP + 5]),
};

const [BX, BY] = BOTTLE;
const COASTER_TOP = DECK_H + 2;
const BODY_R = 19;
const BODY_TOP = COASTER_TOP + 32;
const SHOULDER_TOP = BODY_TOP + 2.2;
const NECK_TOP = SHOULDER_TOP + 2.2;
const COLLAR_TOP = NECK_TOP + 2.6;
const CAP_TOP = COLLAR_TOP + 11;
const BULB_TOP = CAP_TOP + 10;
const SHINE_ANGLE = VIEW_ANGLE + 0.95;
const shineAt = (r: number, z0: number, z1: number) =>
  line([
    [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z0],
    [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z1],
  ]);

export const BOTTLE_ART = {
  coaster: cyl(BX, BY, 25, DECK_H, COASTER_TOP - DECK_H, 44),
  coasterRing: ring(BX, BY, 22.6, COASTER_TOP, 40),
  coasterDots: [0, 1, 2, 3].map((index) => {
    const angle = Math.PI / 4 + (Math.PI / 2) * index;
    return at([BX + 23.8 * Math.cos(angle), BY + 23.8 * Math.sin(angle), COASTER_TOP]);
  }),
  body: cyl(BX, BY, BODY_R, COASTER_TOP, BODY_TOP - COASTER_TOP, 44),
  level: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 21, P),
  foot: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 2.4, P),
  shine: shineAt(BODY_R, COASTER_TOP + 5, BODY_TOP - 4),
  shoulder: cyl(BX, BY, 15.5, BODY_TOP, SHOULDER_TOP - BODY_TOP, 40),
  neck: cyl(BX, BY, 11, SHOULDER_TOP, NECK_TOP - SHOULDER_TOP, 36),
  collar: cyl(BX, BY, 9.4, NECK_TOP, COLLAR_TOP - NECK_TOP, 36),
  cap: cyl(BX, BY, 10, COLLAR_TOP, CAP_TOP - COLLAR_TOP, 36),
  capKnurl: knurl(BX, BY, 10, COLLAR_TOP + 1, CAP_TOP - 1, 15),
  capRing: ring(BX, BY, 7.2, CAP_TOP, 32),
  bulb: cyl(BX, BY, 6, CAP_TOP, BULB_TOP - CAP_TOP, 32),
  bulbShine: shineAt(6, CAP_TOP + 2, BULB_TOP - 2),
  bulbRing: ring(BX, BY, 3.4, BULB_TOP, 24),
  tip: at([BX, BY, BULB_TOP]),
};

export const LABEL = `A lab spot plate on a bench. The plate has six channels, each holding a pair of glass drops, R30 and R22, at gaps of ${GAPS.join(", ")} px, drawn to scale against a ruler on the rim. On the right, beside a dropper bottle, a fan of six feeler blades, one for each gap, turns on a pivot over a protractor marked 0 to ${MERGE_MOST} px; the top blade is merge. As it opens, each blade stops at its own gap, and the pair with that gap runs together into one piece of glass.`;
```

## examples/spot-plate/live.tsx

```
"use client";

import * as React from "react";
import { IsoFigure } from "../../kit/react/draw";
import { asksForStillness, useReadout } from "../../kit/react/live";
import { keyOf, rowsOf, type GlassShape, type RowShape } from "./drops";
import { fanOf, type FanPose } from "./fan";
import { HEIGHT, MERGE, MERGE_LEAST, MERGE_MOST, MERGE_STEP, ROWS, WIDTH, mergeAtX, readoutOf, roundMerge, spokenOf } from "./rig";

const FOLLOW = 150;
const GLIDE = 560;
const SETTLED = 0.004;
const TOUR_FIRST = 1800;
const TOUR_BACK = 4200;
const TOUR_TRAVEL = 1400;
const TOUR: { merge: number; hold: number }[] = [
  { merge: MERGE_MOST, hold: 2400 },
  { merge: MERGE_LEAST, hold: 2200 },
  { merge: MERGE, hold: 3800 },
];
const BLADES = ROWS + 1;

function Glass({ shape, joined }: { shape: GlassShape; joined: boolean }) {
  const quiet = joined ? "lo" : "faint";
  return (
    <g className="iso-solid ml-glass" data-joined={joined ? "" : undefined}>
      <path className="iso-fill" d={shape.fill} />
      {shape.shades.map((shade, index) => (
        <path key={index} className="iso-shade" data-shade={shade.shade} d={shade.d} />
      ))}
      <path className="iso-top" d={shape.top} />
      <path className="iso-line" data-tone={quiet} d={shape.crease} />
      {shape.bevel ? <path className="iso-line iso-bevel" data-tone={quiet} d={shape.bevel} /> : null}
      <path className="iso-line iso-edge" data-tone={joined ? "hi" : "mid"} d={shape.outline} />
      {shape.shine ? <path className="iso-line ml-shine" data-tone="mid" d={shape.shine} /> : null}
    </g>
  );
}

const Row = React.memo(function Row({ shape }: { shape: RowShape }) {
  return (
    <>
      <g>
        <g>
          {shape.glass.map((glass, index) => (
            <Glass key={index} shape={glass} joined={shape.joined} />
          ))}
        </g>
      </g>
      {shape.neck.length ? (
        <g>
          <g className="ml-neck">
            {shape.neck.map((glint, index) => (
              <path key={index} className="iso-line" data-tone="lit" strokeOpacity={glint.alpha < 1 ? glint.alpha : undefined} d={glint.d} />
            ))}
          </g>
        </g>
      ) : null}
    </>
  );
});

interface BladeNodes {
  group: SVGGElement | null;
  fill: SVGPathElement | null;
  shades: (SVGPathElement | null)[];
  top: SVGPathElement | null;
  crease: SVGPathElement | null;
  outline: SVGPathElement | null;
  line: SVGPathElement | null;
}

const emptyBlade = (): BladeNodes => ({ group: null, fill: null, shades: [null, null, null, null], top: null, crease: null, outline: null, line: null });

function apply(nodes: BladeNodes[], halo: SVGPathElement | null, pose: FanPose) {
  halo?.setAttribute("d", pose.halo);
  pose.blades.forEach((blade, index) => {
    const node = nodes[index];
    if (!node) return;
    node.fill?.setAttribute("d", blade.fill);
    blade.shades.forEach((d, shade) => node.shades[shade]?.setAttribute("d", d));
    node.top?.setAttribute("d", blade.top);
    node.crease?.setAttribute("d", blade.crease);
    node.outline?.setAttribute("d", blade.outline);
    node.line?.setAttribute("d", blade.line);
    if (index < ROWS) {
      if (pose.out[index]) node.group?.setAttribute("data-out", "");
      else node.group?.removeAttribute("data-out");
    }
  });
}

interface Props {
  label: string;
  back: React.ReactNode;
  deck: React.ReactNode;
  front: React.ReactNode;
}

export function LiquidLive({ label, back, deck, front }: Props) {
  const { anchor, say } = useReadout<HTMLDivElement>();
  const root = React.useRef<HTMLDivElement | null>(null);
  const blades = React.useRef<BladeNodes[]>(Array.from({ length: BLADES }, emptyBlade));
  const halo = React.useRef<SVGPathElement | null>(null);
  const rest = React.useMemo(() => fanOf(MERGE), []);
  const goal = React.useRef(MERGE);
  const lead = React.useRef(MERGE);
  const shown = React.useRef(MERGE);
  const ease = React.useRef(FOLLOW);
  const frame = React.useRef(0);
  const last = React.useRef(0);
  const [quant, setQuant] = React.useState(keyOf(MERGE) / 4);
  const [hovering, setHovering] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [visible, setVisible] = React.useState(false);
  const [idle, setIdle] = React.useState(true);
  const [poke, setPoke] = React.useState(0);

  const rows = React.useMemo(() => rowsOf(quant), [quant]);
  const value = roundMerge(quant);

  const draw = React.useCallback((merge: number) => {
    apply(blades.current, halo.current, fanOf(merge));
    setQuant(keyOf(merge) / 4);
  }, []);

  const stop = React.useCallback(() => {
    cancelAnimationFrame(frame.current);
    frame.current = 0;
  }, []);

  const run = React.useCallback(() => {
    if (frame.current) return;
    last.current = 0;
    const tick = (now: number) => {
      const dt = last.current ? Math.min(now - last.current, 64) : 16;
      last.current = now;
      const blend = 1 - Math.exp(-dt / ease.current);
      lead.current += (goal.current - lead.current) * blend;
      shown.current += (lead.current - shown.current) * blend;
      const done = Math.abs(goal.current - lead.current) < SETTLED && Math.abs(goal.current - shown.current) < SETTLED;
      if (done) {
        lead.current = goal.current;
        shown.current = goal.current;
      }
      draw(shown.current);
      frame.current = done ? 0 : requestAnimationFrame(tick);
    };
    frame.current = requestAnimationFrame(tick);
  }, [draw]);

  const aim = React.useCallback(
    (merge: number, pace = FOLLOW) => {
      const next = Math.min(MERGE_MOST, Math.max(MERGE_LEAST, merge));
      ease.current = pace;
      goal.current = next;
      if (asksForStillness()) {
        stop();
        lead.current = next;
        shown.current = next;
        draw(next);
        return;
      }
      run();
    },
    [draw, run, stop],
  );

  React.useEffect(() => stop, [stop]);

  React.useEffect(() => {
    say(readoutOf(value));
  }, [value, say]);

  React.useEffect(() => {
    const node = root.current;
    if (!node) return;
    const observer = new IntersectionObserver((entries) => setVisible(Boolean(entries.at(-1)?.isIntersecting)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  React.useEffect(() => {
    if (!visible) stop();
  }, [visible, stop]);

  const engaged = hovering || focused;

  React.useEffect(() => {
    if (engaged) {
      setIdle(false);
      return;
    }
    const timer = window.setTimeout(() => setIdle(true), TOUR_BACK);
    return () => window.clearTimeout(timer);
  }, [engaged, poke]);

  const touring = visible && idle && !engaged;

  React.useEffect(() => {
    if (!touring || asksForStillness()) return;
    let timer = 0;
    let step = 0;
    const next = () => {
      const stop = TOUR[step % TOUR.length]!;
      aim(stop.merge, GLIDE);
      step += 1;
      timer = window.setTimeout(next, stop.hold + TOUR_TRAVEL);
    };
    timer = window.setTimeout(next, TOUR_FIRST);
    return () => window.clearTimeout(timer);
  }, [touring, aim]);

  const mergeAt = (clientX: number) => {
    const svg = root.current?.querySelector("svg");
    const box = svg?.getBoundingClientRect();
    if (!box || !box.width) return null;
    return mergeAtX(((clientX - box.left) / box.width) * WIDTH);
  };

  const hold = () => {
    setIdle(false);
    setPoke((count) => count + 1);
  };

  const follow = (clientX: number) => {
    const merge = mergeAt(clientX);
    if (merge !== null) aim(merge, FOLLOW);
  };

  const stepBy = (by: number) => roundMerge(goal.current + by);

  const bindBlade = (index: number) => ({
    group: (node: SVGGElement | null) => {
      blades.current[index]!.group = node;
    },
    path: (key: "fill" | "top" | "crease" | "outline" | "line") => (node: SVGPathElement | null) => {
      blades.current[index]![key] = node;
    },
    shade: (shade: number) => (node: SVGPathElement | null) => {
      blades.current[index]!.shades[shade] = node;
    },
  });

  return (
    <div
      ref={(node) => {
        root.current = node;
        anchor.current = node;
      }}
      className="ml"
      role="slider"
      tabIndex={0}
      aria-label="Merge, in pixels"
      aria-valuemin={MERGE_LEAST}
      aria-valuemax={MERGE_MOST}
      aria-valuenow={value}
      aria-valuetext={spokenOf(value)}
      onPointerEnter={(event) => {
        if (event.pointerType === "mouse") setHovering(true);
      }}
      onPointerMove={(event) => {
        if (event.pointerType === "mouse" || event.currentTarget.hasPointerCapture(event.pointerId)) {
          hold();
          follow(event.clientX);
        }
      }}
      onPointerDown={(event) => {
        if (event.pointerType !== "mouse") event.currentTarget.setPointerCapture(event.pointerId);
        hold();
        follow(event.clientX);
      }}
      onPointerUp={(event) => {
        if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
      }}
      onPointerLeave={(event) => {
        if (event.pointerType !== "mouse") return;
        setHovering(false);
        aim(MERGE, GLIDE);
      }}
      onFocus={() => {
        setFocused(true);
        aim(roundMerge(shown.current), FOLLOW);
      }}
      onBlur={() => setFocused(false)}
      onKeyDown={(event) => {
        let next: number | null = null;
        const big = event.shiftKey ? 4 : 1;
        if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = stepBy(-MERGE_STEP * big);
        else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = stepBy(MERGE_STEP * big);
        else if (event.key === "PageDown") next = stepBy(-MERGE_STEP * 8);
        else if (event.key === "PageUp") next = stepBy(MERGE_STEP * 8);
        else if (event.key === "Home") next = MERGE_LEAST;
        else if (event.key === "End") next = MERGE_MOST;
        if (next === null) return;
        event.preventDefault();
        hold();
        aim(next, FOLLOW);
      }}
    >
      <IsoFigure width={WIDTH} height={HEIGHT} label={label}>
        {back}
        {rows.map((shape) => (
          <Row key={shape.row} shape={shape} />
        ))}
        {deck}
        <g>
          <g>
            <path ref={(node) => void (halo.current = node)} className="iso-halo ml-fan-halo" d={rest.halo} />
            {rest.blades.map((blade, index) => {
              const bind = bindBlade(index);
              const isLead = index === ROWS;
              return (
                <g
                  key={index}
                  ref={bind.group}
                  className="iso-solid ml-blade"
                  data-lead={isLead ? "" : undefined}
                  data-lit={isLead ? "" : undefined}
                  data-out={!isLead && rest.out[index] ? "" : undefined}
                >
                  <path ref={bind.path("fill")} className="iso-fill" d={blade.fill} />
                  {[0, 1, 2, 3].map((shade) => (
                    <path key={shade} ref={bind.shade(shade)} className="iso-shade" data-shade={shade} d={blade.shades[shade] ?? ""} />
                  ))}
                  <path ref={bind.path("top")} className="iso-top" d={blade.top} />
                  <path ref={bind.path("crease")} className="iso-line" data-tone="faint" d={blade.crease} />
                  <path ref={bind.path("outline")} className="iso-line iso-edge" data-tone={isLead ? "hi" : "mid"} d={blade.outline} />
                  <path ref={bind.path("line")} className="iso-line ml-blade-line" data-tone={isLead ? "lit" : "lo"} d={blade.line} />
                </g>
              );
            })}
          </g>
        </g>
        {front}
      </IsoFigure>
    </div>
  );
}
```

## examples/spot-plate/rig.ts

```ts
import { cameraOf, type Flat, iso, type Plan, type Point3, type Projection } from "../../kit/iso-kit";

export const RADII = [30, 22] as const;
export const GAPS = [4, 10, 16, 24, 32, 44] as const;
export const MERGE = 20;
export const MERGE_LEAST = 0;
export const MERGE_MOST = 48;
export const MERGE_STEP = 1;

const UNIFORM = 2;
const SIDE = 0.35;
const FACING = 0.65;
const DEPTH = 0.25;

export const [RA, RB] = RADII;
export const ROWS = GAPS.length;
const FAR = GAPS[ROWS - 1];

export const spanOf = (gap: number) => RA + gap + RB;
export const reachOf = (merge: number) => merge * UNIFORM;
export const sideOf = (merge: number) => Math.round(reachOf(merge) * SIDE * 10) / 10;
export const seenGapOf = (gap: number, merge: number) => (gap >= 2 * merge ? gap : gap <= merge ? 0 : 2 * Math.sqrt(merge * (gap - merge)));
export const joinedAt = (merge: number) => GAPS.filter((gap) => gap < merge);

export type Field = (u: number, v: number) => number;

export function fieldOf(gap: number, merge: number): Field {
  const span = spanOf(gap);
  const most = reachOf(merge);
  return (u, v) => {
    const qu = u - span;
    const la = Math.sqrt(u * u + v * v);
    const lb = Math.sqrt(qu * qu + v * v);
    const a = la - RA;
    const b = lb - RB;
    const nearest = a < b ? a : b;
    if (most <= 0) return nearest;
    const spread = a < b ? b - a : a - b;
    if (spread >= most) return nearest;
    const raw = 0.5 - (0.5 * (u * qu + v * v)) / Math.max(la * lb, 0.0001);
    const facing = raw < 0 ? 0 : raw > 1 ? 1 : raw;
    const reach = most * (SIDE + FACING * facing);
    const h = Math.max(reach - spread, 0) / reach;
    return nearest - h * h * reach * DEPTH;
  };
}

function halfWidth(field: Field, u: number) {
  if (field(u, 0) >= 0) return 0;
  let low = 0;
  let high = RA + 12;
  for (let round = 0; round < 30; round++) {
    const mid = (low + high) / 2;
    if (field(u, mid) < 0) low = mid;
    else high = mid;
  }
  return low;
}

export function neckOf(gap: number, merge: number) {
  if (gap >= merge) return 0;
  const field = fieldOf(gap, merge);
  const from = RA - 8;
  const to = RA + gap + 8;
  const coarse = 48;
  let best = Infinity;
  let at = (from + to) / 2;
  for (let step = 0; step <= coarse; step++) {
    const u = from + ((to - from) * step) / coarse;
    const width = halfWidth(field, u);
    if (width > 0 && width < best) {
      best = width;
      at = u;
    }
  }
  const golden = (Math.sqrt(5) - 1) / 2;
  let low = Math.max(from, at - (to - from) / coarse);
  let high = Math.min(to, at + (to - from) / coarse);
  for (let round = 0; round < 24; round++) {
    const c = high - golden * (high - low);
    const d = low + golden * (high - low);
    if (halfWidth(field, c) < halfWidth(field, d)) high = d;
    else low = c;
  }
  return 2 * halfWidth(field, (low + high) / 2);
}

export const WIDTH = 600;
export const HEIGHT = 340;
const PAD_X = 80;
const PAD_Y = 34;
export const AZIMUTH = 50;

export const MARGIN = 6;
export const PITCH = 84;
export const WELL_HALF = RA + MARGIN;
export const BIG_Y = MARGIN + RA;
export const DATUM_Y = BIG_Y + RA;
export const WELL_D = DATUM_Y + FAR + 2 * RB + MARGIN;
export const BORDER = 14;

export const COLS = 3;
export const ROW_RIM = 16;
export const rowX = (row: number) => (row % COLS) * PITCH;
export const rowY = (row: number) => Math.floor(row / COLS) * (WELL_D + ROW_RIM);

export const WELLS: Plan[] = GAPS.map((_, row) => ({ x: rowX(row) - WELL_HALF, y: rowY(row), w: WELL_HALF * 2, d: WELL_D, r: WELL_HALF }));

export const TRAY: Plan = {
  x: -WELL_HALF - BORDER,
  y: -BORDER,
  w: rowX(COLS - 1) + (WELL_HALF + BORDER) * 2,
  d: rowY(ROWS - 1) + WELL_D + BORDER * 2,
  r: 16,
};
export const TRAY_H = 13;
export const WELL_SINK = 3;
export const FLOOR = TRAY_H - WELL_SINK;
export const THICK = 8;
export const GLASS_TOP = FLOOR + THICK;

export const DECK: Plan = { x: TRAY.x + TRAY.w + 10, y: TRAY.y, w: 172, d: TRAY.d, r: 12 };
export const DECK_H = 6;
export const PIVOT: [number, number] = [DECK.x + 22, DECK.y + DECK.d / 2 + 6];
export const BLADE_LONG = 104;
export const LEAD_LONG = 113;
export const STOP_R = 111;
export const SCALE_R = 120;
export const SWEEP = 180;
export const WASHER_H = 0.8;
export const STACK_Z = DECK_H + WASHER_H;
export const thicknessOf = (gap: number) => 0.45 + gap / 20;
export const LEAD_H = 1.1;

export const BLADE_Z: number[] = (() => {
  const out: number[] = [];
  let z = STACK_Z;
  for (const gap of GAPS) {
    out.push(z);
    z += thicknessOf(gap) + 0.12;
  }
  out.push(z);
  return out;
})();
export const STACK_TOP = BLADE_Z[ROWS]! + LEAD_H;

export const angleOf = (merge: number) => (Math.min(Math.max(merge, MERGE_LEAST), MERGE_MOST) / MERGE_MOST) * SWEEP;

export const BOTTLE: [number, number] = [DECK.x + DECK.w - 33, DECK.y + 35];
export const BOTTLE_TOP = DECK_H + 64;

export const BASE: Plan = { x: TRAY.x - 14, y: TRAY.y - 14, w: DECK.x + DECK.w + 14 - (TRAY.x - 14), d: TRAY.d + 28, r: 10 };
export const BASE_H = 10;
export const FOOT_H = 5;

const RADIANS = Math.PI / 180;

function fit(points: Point3[]): Projection {
  const unitView: Projection = { origin: [0, 0], scale: 1, azimuth: AZIMUTH };
  const flat = points.map((point) => iso(point, unitView));
  const xs = flat.map(([x]) => x);
  const ys = flat.map(([, y]) => y);
  const left = Math.min(...xs);
  const right = Math.max(...xs);
  const up = Math.min(...ys);
  const down = Math.max(...ys);
  const scale = Math.min((WIDTH - PAD_X * 2) / (right - left), (HEIGHT - PAD_Y * 2) / (down - up));
  return { origin: [WIDTH / 2 - ((left + right) / 2) * scale, HEIGHT / 2 - ((up + down) / 2) * scale], scale, azimuth: AZIMUTH };
}

const box = ({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] => {
  const out: Point3[] = [];
  for (const px of [x, x + w]) for (const py of [y, y + d]) for (const pz of [z0, z1]) out.push([px, py, pz]);
  return out;
};

export const P = fit([...box(BASE, -BASE_H - FOOT_H, 0), ...box(DECK, 0, STACK_TOP + 6), [BOTTLE[0], BOTTLE[1], BOTTLE_TOP]]);
export const at = (point: Point3): Flat => iso(point, P);

const CAMERA = cameraOf(P);
export const VIEW: [number, number] = [CAMERA.cosA, CAMERA.sinA];
export const LIGHT: [number, number] = [-CAMERA.sinA, CAMERA.cosA];
export const CLIMB = CAMERA.sinE / CAMERA.cosE;
export const K = CAMERA.k;
export const SIN_A = Math.sin(AZIMUTH * RADIANS);
export const COS_A = Math.cos(AZIMUTH * RADIANS);

const SLIDE_FROM = 0.16;
const SLIDE_TO = 0.84;

export function mergeAtX(x: number) {
  const share = (x / WIDTH - SLIDE_FROM) / (SLIDE_TO - SLIDE_FROM);
  return MERGE_LEAST + Math.min(1, Math.max(0, share)) * (MERGE_MOST - MERGE_LEAST);
}

export const roundMerge = (merge: number) => Math.min(MERGE_MOST, Math.max(MERGE_LEAST, Math.round(merge)));

const listOf = (values: readonly (number | string)[]) =>
  values.length < 2 ? values.join("") : `${values.slice(0, -1).join(", ")} and ${values[values.length - 1]}`;

export function readoutOf(merge: number) {
  const shown = roundMerge(merge);
  return `merge ${shown} px · ${joinedAt(shown).length} of ${ROWS} joined`;
}

export function joinedListOf(merge: number) {
  const joined = joinedAt(roundMerge(merge));
  if (!joined.length) return "none of the six";
  if (joined.length === ROWS) return "all six";
  return `${listOf(joined)} px`;
}

export function bladesOutOf(merge: number) {
  const out = joinedAt(roundMerge(merge)).length;
  if (!out) return "no blade is out";
  if (out === ROWS) return "all six blades are out";
  return out === 1 ? "one blade is out" : `${["", "", "two", "three", "four", "five"][out]} blades are out`;
}

export function spokenOf(merge: number) {
  const shown = roundMerge(merge);
  const joined = joinedAt(shown);
  const apart = GAPS.filter((gap) => gap >= shown);
  const head = `merge ${shown} px: ${joined.length} of ${ROWS} pairs joined`;
  if (!joined.length) return `${head}, all six apart`;
  if (!apart.length) return `${head}, every gap from ${GAPS[0]} to ${FAR} px`;
  return `${head}, gaps ${listOf(joined)} px; ${listOf(apart)} px ${apart.length > 1 ? "stay" : "stays"} apart`;
}

export const listText = listOf;
```

## examples/spot-plate/spot-plate.css

```

```

## examples/test-rig

```

```

## examples/test-rig/art.tsx

```
import * as React from "react";
import { Dots, FaceText, Line, Solid } from "../../kit/react/draw";
import { BASE_ART, CARRIAGE_ART, DIAL_ART, FINGER_ART, FRAME_ART, PLUNGER_ART, PROBE_ART, RAIL_ART, REST_OUTLINE, RIDER, RODS } from "./geometry";
import { StretchLive } from "./live";

const BASE = (
  <g key="base">
    <path className="iso-halo" d={BASE_ART.halo} />
    {BASE_ART.feet.map((parts, index) => (
      <g key={index}>
        {parts.map((paths, part) => (
          <Solid key={part} paths={paths} tone={part ? "mid" : "lo"} crease="faint" />
        ))}
      </g>
    ))}
    <Solid paths={BASE_ART.slab} tone="mid" crease="faint" />
    <Line d={BASE_ART.groove} tone="lo" />
    <Line d={BASE_ART.screwRings} tone="lo" />
    <Dots points={BASE_ART.screws} size={0.5} tone="mid" />
    <Solid paths={BASE_ART.plate} tone="lo" crease="none" />
    <Line d={BASE_ART.plateLine} tone="faint" />
    <Line d={BASE_ART.plateRule} tone="lo" />
    <Dots points={BASE_ART.plateScrews} size={0.45} tone="mid" />
  </g>
);

const RAIL = (
  <g key="rail">
    {RAIL_ART.feet.map((paths, index) => (
      <Solid key={index} paths={paths} tone="lo" crease="faint" />
    ))}
    <Dots points={RAIL_ART.footScrews} size={0.45} tone="mid" />
    {RAIL_ART.posts.map((paths, index) => (
      <Solid key={index} paths={paths} tone="mid" crease="faint" />
    ))}
    <Line d={RAIL_ART.postLines} tone="faint" />
    <Solid paths={RAIL_ART.rail} tone="mid" crease="faint" />
    <Line d={RAIL_ART.ruleBase} tone="faint" />
    <Line d={RAIL_ART.ruleMinor} tone="lo" />
    <Line d={RAIL_ART.ruleMajor} tone="mid" />
    <Dots points={RAIL_ART.railScrews} size={0.5} tone="mid" />
    {RAIL_ART.stops.map((paths, index) => (
      <Solid key={index} paths={paths} tone="mid" crease="faint" />
    ))}
  </g>
);

const RIDER_ART = (
  <g key="rider-art">
    <Solid paths={RIDER.body} tone="hi" crease="faint" />
    <Line d={RIDER.tick} tone="hi" />
  </g>
);

const BLOCK = (
  <g key="block">
    <Solid paths={PROBE_ART.block} tone="mid" crease="faint" />
    <Line d={PROBE_ART.blockLine} tone="faint" />
    <Dots points={PROBE_ART.blockScrews} size={0.45} tone="mid" />
    <Solid paths={PROBE_ART.thumb} tone="mid" crease="faint" />
    <Line d={PROBE_ART.thumbKnurl} tone="lo" />
    <Solid paths={PROBE_ART.pointer} tone="mid" crease="none" />
    <Line d={PROBE_ART.index} tone="lit" />
  </g>
);

const FRAME_BACK = (
  <g key="frame-back">
      <Solid paths={FRAME_ART.back} tone="mid" crease="faint" />
      <Dots points={FRAME_ART.backScrews} size={0.5} tone="mid" />
      <Solid paths={FRAME_ART.left} tone="mid" crease="faint" />
      <Dots points={FRAME_ART.leftScrews} size={0.5} tone="mid" />
      <Solid paths={FRAME_ART.floor} tone="lo" crease="none" flat />
      <Line d={FRAME_ART.grid} tone="faint" />
      <Line d={FRAME_ART.leftBores} tone="lo" />
  </g>
);

const RODS_ART = (
  <g key="rods-art">
    {RODS.map((paths, index) => (
      <Solid key={index} paths={paths} tone="mid" crease="none" />
    ))}
  </g>
);

const CARRIAGE = (
  <g key="carriage">
    <path className="iso-halo ms-shadow" d={CARRIAGE_ART.halo} />
    <Solid paths={CARRIAGE_ART.plate} tone="mid" crease="faint" />
    <Line d={CARRIAGE_ART.seatInner} tone="faint" />
    <Line d={CARRIAGE_ART.seat} tone="lo" />
    <Dots points={CARRIAGE_ART.rivets} size={0.4} tone="lo" />
    {CARRIAGE_ART.bushes.map((paths, index) => (
      <Solid key={index} paths={paths} tone="mid" crease="faint" />
    ))}
    <Dots points={CARRIAGE_ART.bushScrews} size={0.45} tone="mid" />
    <Line d={CARRIAGE_ART.bushBores} tone="lo" />
    <Solid paths={CARRIAGE_ART.contact} tone="mid" crease="faint" />
  </g>
);

const PLUNGER = (
  <g key="plunger">
    <Solid paths={PLUNGER_ART.rod} tone="mid" crease="none" />
    <Solid paths={PLUNGER_ART.collar} tone="mid" crease="faint" />
  </g>
);

const REST = <Line key="rest" d={REST_OUTLINE} tone="mid" dotted />;

const FRAME_FRONT = (
  <g key="frame-front">
      <Solid paths={FRAME_ART.right} tone="mid" crease="faint" />
      <Dots points={FRAME_ART.rightScrews} size={0.5} tone="mid" />
      <Line d={FRAME_ART.rodEnds} tone="lo" />
      <Dots points={FRAME_ART.rodNuts} size={0.55} tone="mid" />
      <Line d={FRAME_ART.bore} tone="lo" />
      <Solid paths={FRAME_ART.front} tone="mid" crease="faint" />
      <Dots points={FRAME_ART.frontScrews} size={0.5} tone="mid" />
      <Line d={FRAME_ART.ticksMinor} tone="lo" />
      <Line d={FRAME_ART.ticksMajor} tone="mid" />
      <Line d={FRAME_ART.zero} tone="hi" />
  </g>
);

const DIAL = (
  <g key="dial">
    <Solid paths={DIAL_ART.stand} tone="lo" crease="faint" />
    <Dots points={DIAL_ART.standScrews} size={0.45} tone="mid" />
    <Solid paths={DIAL_ART.sleeve} tone="mid" crease="faint" />
    <Solid paths={DIAL_ART.clamp} tone="mid" crease="none" />
    <Solid paths={DIAL_ART.lug} tone="mid" crease="faint" />
    <Solid paths={DIAL_ART.puck} tone="mid" crease="faint" />
    <Line d={DIAL_ART.band} tone="faint" />
    <Line d={DIAL_ART.knurl} tone="faint" />
    <Line d={DIAL_ART.bezel} tone="lo" />
    <Line d={DIAL_ART.face} tone="faint" />
    <Line d={DIAL_ART.minor} tone="lo" />
    <Line d={DIAL_ART.major} tone="mid" />
    <Dots points={DIAL_ART.give} size={0.55} tone="hi" />
    <FaceText transform={DIAL_ART.label} size={2.6} tone="lo" anchor="middle">
      px
    </FaceText>
    <Solid paths={DIAL_ART.crown} tone="mid" crease="faint" />
    <Solid paths={DIAL_ART.crownCap} tone="mid" crease="faint" />
  </g>
);

const FINGER = (
  <g key="finger">
    <Solid paths={FINGER_ART.stem} tone="mid" crease="none" />
    <Line d={FINGER_ART.stemMarks} tone="lo" />
    <Solid paths={FINGER_ART.collar} tone="mid" crease="faint" />
    <Solid paths={FINGER_ART.pad} tone="hi" crease="faint" />
    <Line d={FINGER_ART.padRing} tone="lo" />
  </g>
);

const ARM = (
  <g key="arm">
    <Solid paths={PROBE_ART.collar} tone="mid" crease="faint" />
    <Solid paths={PROBE_ART.housing} tone="mid" crease="faint" />
    <Line d={PROBE_ART.housingSeam} tone="faint" />
    <Solid paths={PROBE_ART.lock} tone="mid" crease="none" />
    <Solid paths={PROBE_ART.lockHead} tone="mid" crease="faint" />
    <Solid paths={PROBE_ART.cap} tone="mid" crease="faint" />
    <Solid paths={PROBE_ART.arm} tone="mid" crease="faint" />
    <Line d={PROBE_ART.armLine} tone="faint" />
    <Dots points={PROBE_ART.armScrews} size={0.45} tone="mid" />
  </g>
);

const LABEL =
  "A test rig for a glass button. A 120 × 32 capsule of glass sits on a carriage that slides on two guide rods inside a frame, held at rest by four coil springs, two at each end. A finger probe hangs from a carriage on a rail behind it: it comes down to press the glass, which swells, then slides along the rail to pull, and the glass leans after it, giving less the further it goes. A dial gauge at the end of the frame reads the lean. Let go and the springs bring it back, once past rest.";

export function StretchArt() {
  return (
    <StretchLive
      label={LABEL}
      base={BASE}
      rail={RAIL}
      rider={RIDER_ART}
      block={BLOCK}
      frameBack={FRAME_BACK}
      rods={RODS_ART}
      carriage={CARRIAGE}
      plunger={PLUNGER}
      rest={REST}
      frameFront={FRAME_FRONT}
      dial={DIAL}
      finger={FINGER}
      arm={ARM}
    />
  );
}
```

## examples/test-rig/figure.tsx

```
import { Plate } from "../../kit/react/draw";
import { StretchArt } from "./art";
import { readoutOf, PULLED } from "./rig";
import "./stretch.css";

export function TestRigFigure() {
  return (
    <Plate
      fig="Fig 6"
      title="Press and pull"
      hint="Press, then pull ← →"
      readout="120.0 × 32.0 · lean 0.0 px"
      keys={[
        { mark: "raised", label: "The glass while it is held" },
        { mark: "dotted", label: "Where it rests" },
        { mark: "edge", label: "The pull, from where I pressed to the finger" },
      ]}
      caption={`A 120 × 32 glass button on a test rig: a carriage on two guide rods held by four springs, a finger probe on a rail behind it, and a dial gauge that reads the lean. Pulled 80 px it leans ${readoutOf(PULLED).split("lean ")[1]}.`}
    >
      <StretchArt />
    </Plate>
  );
}
```

## examples/test-rig/geometry.ts

```ts
import { circleRing, extrude, insetPlan, onTop, pathOf, planOutline, slabOf, topMatrix, type Flat, type Plan, type Plane, type Point3 } from "../../kit/iso-kit";
import {
  BAR,
  BAR_H,
  BASE,
  BASE_H,
  BUSH_D,
  BUSH_END,
  BUSH_W,
  CARRIAGE,
  CARRIAGE_H,
  CARRIAGE_TOP,
  CARRIAGE_Z,
  DIAL,
  DIAL_H,
  DIAL_R,
  DIAL_SPAN,
  DIAL_TOP,
  DIAL_Z,
  FOOT_H,
  FRAME_X,
  FRAME_Y,
  GLASS_TOP,
  HOUSING_H,
  HOUSING_R,
  HOUSING_Z,
  P,
  PAD_H,
  PAD_R,
  PLUNGER_Z,
  POST,
  POST_L,
  POST_R,
  PROBE_HOME,
  RAIL_D,
  RAIL_H,
  RAIL_Y,
  RAIL_Z,
  ROD_HALF,
  ROD_Y,
  ROD_Z,
  STEM,
  at,
} from "./view";

const solid = (plan: Plan, z: number, h: number, steps = 6, bevel = 0) => slabOf(plan, z, h, P, steps, bevel);
const cylinder = (cx: number, cy: number, r: number, z: number, h: number, steps = 32) => extrude(circleRing(cx, cy, r, steps), z, h, P, { convex: true });
const ring = (cx: number, cy: number, r: number, z: number, steps = 40) => onTop(circleRing(cx, cy, r, steps), z, P, true);
const segment = (a: Point3, b: Point3) => pathOf([at(a), at(b)]);
const join = (paths: string[]) => paths.join("");
const RADIANS = Math.PI / 180;

function knurl(cx: number, cy: number, r: number, z0: number, z1: number, count: number) {
  const lines: string[] = [];
  for (let index = 0; index < count; index++) {
    const angle = (index / count) * Math.PI * 2;
    const nx = Math.cos(angle);
    const ny = Math.sin(angle);
    if (nx * Math.cos(57 * RADIANS) + ny * Math.sin(57 * RADIANS) < 0.15) continue;
    lines.push(segment([cx + r * nx, cy + r * ny, z0 + 0.4], [cx + r * nx, cy + r * ny, z1 - 0.4]));
  }
  return join(lines);
}

function sideArc(cx: number, cy: number, r: number, z: number, steps = 24) {
  const points: Flat[] = [];
  for (let step = 0; step <= steps; step++) {
    const angle = (57 - 90 + (step / steps) * 180) * RADIANS;
    points.push(at([cx + r * Math.cos(angle), cy + r * Math.sin(angle), z]));
  }
  return pathOf(points);
}

function faceRing(x: number, cy: number, cz: number, r: number, steps = 20) {
  const points: Flat[] = [];
  for (let step = 0; step < steps; step++) {
    const angle = (step / steps) * Math.PI * 2;
    points.push(at([x, cy + r * Math.cos(angle), cz + r * Math.sin(angle)]));
  }
  return pathOf(points, true);
}

const FEET: Plane[] = [
  [BASE.x + 18, BASE.y + 16],
  [BASE.x + BASE.w - 18, BASE.y + 16],
  [BASE.x + 18, BASE.y + BASE.d - 16],
  [BASE.x + BASE.w - 18, BASE.y + BASE.d - 16],
];

const BASE_SCREWS: Plane[] = [
  [BASE.x + 8, BASE.y + 8],
  [BASE.x + BASE.w - 8, BASE.y + 8],
  [BASE.x + 8, BASE.y + BASE.d - 8],
  [BASE.x + BASE.w - 8, BASE.y + BASE.d - 8],
];

const PLATE: Plan = { x: BASE.x + 20, y: FRAME_Y + BAR + 6, w: 56, d: 11, r: 2 };

export const BASE_ART = {
  halo: solid(BASE, -BASE_H - FOOT_H, BASE_H).fill,
  feet: FEET.map(([x, y]) => [cylinder(x, y, 7, -BASE_H - FOOT_H, 1.6), cylinder(x, y, 5.5, -BASE_H - FOOT_H + 1.6, FOOT_H - 1.6)]),
  slab: solid(BASE, -BASE_H, BASE_H, 8, 1.6),
  groove: planOutline(insetPlan(BASE, 4.5), 0, P),
  screws: BASE_SCREWS.map(([x, y]) => at([x, y, 0])),
  screwRings: join(BASE_SCREWS.map(([x, y]) => ring(x, y, 2.2, 0, 16))),
  plate: solid(PLATE, 0, 0.8, 4),
  plateLine: planOutline(insetPlan(PLATE, 1.6), 0.8, P),
  plateScrews: [at([PLATE.x + 3.5, PLATE.y + PLATE.d / 2, 0.8]), at([PLATE.x + PLATE.w - 3.5, PLATE.y + PLATE.d / 2, 0.8])],
  plateRule: join([0, 1, 2].map((row) => onTop([[PLATE.x + 8, PLATE.y + 3.4 + row * 2.1], [PLATE.x + (row === 1 ? 34 : 44), PLATE.y + 3.4 + row * 2.1]], 0.8, P))),
};

const POST_PLANS: Plan[] = [POST_L, POST_R].map((x) => ({ x, y: RAIL_Y - POST / 2, w: POST, d: POST, r: 2 }));
const RAIL: Plan = { x: POST_L - 5, y: RAIL_Y - RAIL_D / 2, w: POST_R + POST - POST_L + 10, d: RAIL_D, r: 2 };
const RAIL_FACE = RAIL_Y + RAIL_D / 2;
const RAIL_TOP = RAIL_Z + RAIL_H;
const RULE_FROM = POST_L + POST + 4;
const RULE_TO = POST_R - 4;

function ruler() {
  const minor: string[] = [];
  const major: string[] = [];
  for (let x = Math.ceil(RULE_FROM / 5) * 5; x <= RULE_TO; x += 5) {
    const big = x % 25 === 0;
    (big ? major : minor).push(segment([x, RAIL_FACE, RAIL_TOP - 1.2], [x, RAIL_FACE, RAIL_TOP - (big ? 5 : 3)]));
  }
  return { minor: join(minor), major: join(major) };
}

const RULE = ruler();

export const RAIL_ART = {
  feet: POST_PLANS.map((plan) => solid(insetPlan(plan, -3), 0, 2, 4)),
  footScrews: POST_PLANS.flatMap((plan) => [at([plan.x - 1, plan.y + POST / 2, 2]), at([plan.x + POST + 1, plan.y + POST / 2, 2])]),
  posts: POST_PLANS.map((plan) => solid(plan, 2, RAIL_Z - 2, 4)),
  postLines: join(POST_PLANS.map((plan) => segment([plan.x + POST, plan.y + POST, 6], [plan.x + POST, plan.y + POST, RAIL_Z - 4]))),
  rail: solid(RAIL, RAIL_Z, RAIL_H, 4, 0.8),
  railScrews: POST_PLANS.map((plan) => at([plan.x + POST / 2, RAIL_Y, RAIL_TOP])),
  ruleMinor: RULE.minor,
  ruleMajor: RULE.major,
  ruleBase: segment([RULE_FROM, RAIL_FACE, RAIL_TOP - 1.2], [RULE_TO, RAIL_FACE, RAIL_TOP - 1.2]),
  stops: [RULE_FROM - 2, RULE_TO + 2].map((x) => solid({ x: x - 1.5, y: RAIL_Y - RAIL_D / 2 - 0.5, w: 3, d: RAIL_D + 1, r: 0.8 }, RAIL_TOP, 2.2, 3)),
};

export const RIDER = {
  body: solid({ x: -1.6, y: RAIL_Y - RAIL_D / 2 - 0.6, w: 3.2, d: RAIL_D + 1.2, r: 0.8 }, RAIL_TOP, 1.4, 3),
  tick: segment([0, RAIL_FACE + 0.6, RAIL_TOP + 1.4], [0, RAIL_FACE + 0.6, RAIL_TOP - 3]),
};

const H = PROBE_HOME;
const BLOCK: Plan = { x: H - 11, y: RAIL_Y - 7, w: 22, d: 14, r: 2.5 };
const BLOCK_Z = RAIL_TOP;
const BLOCK_H = 9;
const ARM_Z = BLOCK_Z + 3;
const ARM_H = 5;
const ARM: Plan = { x: H - 3.5, y: BLOCK.y + BLOCK.d - 1, w: 7, d: -(BLOCK.y + BLOCK.d - 1) + 2, r: 1.5 };

export const PROBE_ART = {
  block: solid(BLOCK, BLOCK_Z, BLOCK_H, 5, 0.8),
  blockLine: planOutline(insetPlan(BLOCK, 2), BLOCK_Z + BLOCK_H, P),
  blockScrews: [at([BLOCK.x + 3, BLOCK.y + 3, BLOCK_Z + BLOCK_H]), at([BLOCK.x + BLOCK.w - 3, BLOCK.y + 3, BLOCK_Z + BLOCK_H])],
  thumb: cylinder(H - 5, RAIL_Y - 1, 2.6, BLOCK_Z + BLOCK_H, 3.2, 20),
  thumbKnurl: knurl(H - 5, RAIL_Y - 1, 2.6, BLOCK_Z + BLOCK_H, BLOCK_Z + BLOCK_H + 3.2, 16),
  pointer: solid({ x: H - 2, y: BLOCK.y + BLOCK.d, w: 4, d: 1, r: 0.4 }, RAIL_TOP - 4, 4 + 2, 2),
  index: segment([H, BLOCK.y + BLOCK.d + 1, RAIL_TOP + 1.5], [H, BLOCK.y + BLOCK.d + 1, RAIL_TOP - 3.6]),
  arm: solid(ARM, ARM_Z, ARM_H, 4, 0.6),
  armLine: onTop([[H, ARM.y + 3], [H, -9]], ARM_Z + ARM_H, P),
  armScrews: [at([H, ARM.y + 2, ARM_Z + ARM_H]), at([H, -2, ARM_Z + ARM_H])],
  housing: cylinder(H, 0, HOUSING_R, HOUSING_Z, HOUSING_H - 2, 32),
  cap: cylinder(H, 0, HOUSING_R + 0.8, HOUSING_Z + HOUSING_H - 2, 2, 32),
  collar: cylinder(H, 0, HOUSING_R + 0.8, HOUSING_Z, 2.4, 32),
  housingSeam: join([HOUSING_Z + 6, HOUSING_Z + 11].map((z) => sideArc(H, 0, HOUSING_R, z))),
  lock: solid({ x: H - 1.6, y: HOUSING_R - 0.5, w: 3.2, d: 3.5, r: 0.8 }, HOUSING_Z + 8, 3.2, 2),
  lockHead: cylinder(H, HOUSING_R + 4.4, 2.2, HOUSING_Z + 7.4, 4.4, 16),
};

const PAD_Z = GLASS_TOP;
export const FINGER_ART = {
  stem: cylinder(H, 0, 1.5, PAD_Z + PAD_H + 2, STEM, 16),
  stemMarks: join([8, 11, 14].map((rise) => sideArc(H, 0, 1.5, PAD_Z + PAD_H + 2 + rise, 12))),
  collar: cylinder(H, 0, 2.6, PAD_Z + PAD_H, 2, 20),
  pad: cylinder(H, 0, PAD_R, PAD_Z, PAD_H, 32),
  padRing: ring(H, 0, PAD_R - 1.2, PAD_Z + PAD_H, 28),
};

const BARS: Plan[] = [
  { x: -FRAME_X - BAR, y: -FRAME_Y - BAR, w: (FRAME_X + BAR) * 2, d: BAR, r: 1.5 },
  { x: -FRAME_X - BAR, y: -FRAME_Y, w: BAR, d: FRAME_Y * 2, r: 0.6 },
  { x: FRAME_X, y: -FRAME_Y, w: BAR, d: FRAME_Y * 2, r: 0.6 },
  { x: -FRAME_X - BAR, y: FRAME_Y, w: (FRAME_X + BAR) * 2, d: BAR, r: 1.5 },
];

const barScrews = (plan: Plan): Flat[] =>
  plan.w > plan.d
    ? [-1, 1].map((side) => at([side * (FRAME_X + BAR / 2), plan.y + BAR / 2, BAR_H]))
    : [-1, 1].map((side) => at([plan.x + BAR / 2, side * (FRAME_Y - 6), BAR_H]));

const FLOOR: Plan = { x: -FRAME_X, y: -FRAME_Y, w: FRAME_X * 2, d: FRAME_Y * 2, r: 0 };

function floorGrid() {
  const lines: string[] = [];
  for (let x = -FRAME_X + 10; x < FRAME_X; x += 10) lines.push(onTop([[x, -FRAME_Y], [x, FRAME_Y]], 0.8, P));
  for (let y = -FRAME_Y + 7.4; y < FRAME_Y; y += 7.4) lines.push(onTop([[-FRAME_X, y], [FRAME_X, y]], 0.8, P));
  return join(lines);
}

function barTicks() {
  const minor: string[] = [];
  const major: string[] = [];
  const y = FRAME_Y + 1;
  for (let x = -40; x <= 40; x += 2) {
    const big = x % 10 === 0;
    (big ? major : minor).push(onTop([[x, y], [x, y + (big ? 3.2 : 1.8)]], BAR_H, P));
  }
  return { minor: join(minor), major: join(major) };
}

const TICKS = barTicks();

export const FRAME_ART = {
  back: solid(BARS[0]!, 0, BAR_H, 4, 0.8),
  left: solid(BARS[1]!, 0, BAR_H, 3, 0.8),
  right: solid(BARS[2]!, 0, BAR_H, 3, 0.8),
  front: solid(BARS[3]!, 0, BAR_H, 4, 0.8),
  backScrews: barScrews(BARS[0]!),
  leftScrews: barScrews(BARS[1]!),
  rightScrews: barScrews(BARS[2]!),
  frontScrews: barScrews(BARS[3]!),
  floor: solid(FLOOR, 0, 0.8, 2),
  grid: floorGrid(),
  ticksMinor: TICKS.minor,
  ticksMajor: TICKS.major,
  zero: onTop([[0, FRAME_Y + 0.6], [0, FRAME_Y + BAR - 0.6]], BAR_H, P),
  rodEnds: join([-ROD_Y, ROD_Y].map((y) => faceRing(FRAME_X + BAR, y, ROD_Z, 3, 16))),
  rodNuts: [-ROD_Y, ROD_Y].map((y) => at([FRAME_X + BAR, y, ROD_Z])),
  bore: faceRing(FRAME_X + BAR, 0, PLUNGER_Z, 2.2, 14),
  leftBores: join([-ROD_Y, ROD_Y].map((y) => faceRing(-FRAME_X, y, ROD_Z, 3, 16))),
};

export const RODS = [-ROD_Y, ROD_Y].map((y) => solid({ x: -FRAME_X, y: y - ROD_HALF, w: FRAME_X * 2, d: ROD_HALF * 2, r: ROD_HALF - 0.2 }, ROD_Z - ROD_HALF, ROD_HALF * 2, 3));

const SEAT: Plan = { x: -63, y: -18, w: 126, d: 36, r: 18 };
const BUSHES: Plan[] = [-1, 1].flatMap((side) =>
  [-ROD_Y, ROD_Y].map((y) => ({ x: side < 0 ? -BUSH_END : BUSH_END - BUSH_W, y: y - BUSH_D / 2, w: BUSH_W, d: BUSH_D, r: 2 })),
);

export const CARRIAGE_ART = {
  halo: solid(insetPlan(CARRIAGE, 3), 0.8, 0.1).fill,
  plate: solid(CARRIAGE, CARRIAGE_Z, CARRIAGE_H, 6, 0.8),
  seat: planOutline(SEAT, CARRIAGE_TOP, P, 10),
  seatInner: planOutline(insetPlan(SEAT, 2.4), CARRIAGE_TOP, P, 10),
  bushes: BUSHES.map((plan) => solid(plan, CARRIAGE_Z - 1, CARRIAGE_H + 4, 3, 0.6)),
  bushScrews: BUSHES.flatMap((plan) => [at([plan.x + 3, plan.y + plan.d / 2, CARRIAGE_TOP + 3]), at([plan.x + plan.w - 3, plan.y + plan.d / 2, CARRIAGE_TOP + 3])]),
  bushBores: join([-ROD_Y, ROD_Y].map((y) => faceRing(BUSH_END, y, ROD_Z, 2.8, 14))),
  contact: solid({ x: BUSH_END - 8, y: -4, w: 8, d: 8, r: 2 }, CARRIAGE_Z, CARRIAGE_H, 3, 0.4),
  rivets: [-56, -28, 0, 28, 56].map((x) => at([x, CARRIAGE.y + CARRIAGE.d - 2.4, CARRIAGE_TOP])),
};

export const PLUNGER_ART = {
  rod: solid({ x: BUSH_END, y: -1.1, w: 44, d: 2.2, r: 1 }, PLUNGER_Z - 1.1, 2.2, 3),
  collar: solid({ x: BUSH_END + 0.4, y: -2.2, w: 2.4, d: 4.4, r: 1 }, PLUNGER_Z - 2.2, 4.4, 3, 0.3),
};

const [DX, DY] = DIAL;
const STAND: Plan = { x: DX - 15, y: DY - 14, w: 30, d: 28, r: 3 };

function dialTicks() {
  const minor: string[] = [];
  const major: string[] = [];
  for (let step = 0; step < DIAL_SPAN; step++) {
    const angle = (step / DIAL_SPAN) * Math.PI * 2;
    const big = step % 5 === 0;
    const sx = Math.sin(angle);
    const sy = -Math.cos(angle);
    const outer = DIAL_R - 2.4;
    const inner = outer - (big ? 2.6 : 1.4);
    (big ? major : minor).push(onTop([[DX + sx * inner, DY + sy * inner], [DX + sx * outer, DY + sy * outer]], DIAL_TOP, P));
  }
  return { minor: join(minor), major: join(major) };
}

const DIAL_TICKS = dialTicks();
const GIVE_ANGLE = (14 / DIAL_SPAN) * Math.PI * 2;
const R_GIVE = DIAL_R - 1.4;

export const DIAL_ART = {
  stand: solid(STAND, 0, DIAL_Z, 4, 0.6),
  standScrews: [at([STAND.x + 3.4, STAND.y + 3.4, DIAL_Z]), at([STAND.x + STAND.w - 3.4, STAND.y + STAND.d - 3.4, DIAL_Z]), at([STAND.x + 3.4, STAND.y + STAND.d - 3.4, DIAL_Z]), at([STAND.x + STAND.w - 3.4, STAND.y + 3.4, DIAL_Z])],
  sleeve: solid({ x: FRAME_X + BAR, y: -2.6, w: DX - DIAL_R - FRAME_X - BAR + 0.6, d: 5.2, r: 1 }, PLUNGER_Z - 2.6, 5.2, 3),
  clamp: cylinder(FRAME_X + BAR + 3.2, 0, 1.6, PLUNGER_Z + 2.6, 2, 12),
  puck: cylinder(DX, DY, DIAL_R, DIAL_Z, DIAL_H, 56),
  bezel: ring(DX, DY, DIAL_R - 1, DIAL_TOP, 56),
  face: ring(DX, DY, DIAL_R - 2.4, DIAL_TOP, 56),
  knurl: knurl(DX, DY, DIAL_R, DIAL_Z + DIAL_H - 3, DIAL_Z + DIAL_H, 56),
  band: sideArc(DX, DY, DIAL_R, DIAL_Z + DIAL_H - 3, 40),
  minor: DIAL_TICKS.minor,
  major: DIAL_TICKS.major,
  give: [-1, 1].map((side) => at([DX + Math.sin(side * GIVE_ANGLE) * R_GIVE, DY - Math.cos(side * GIVE_ANGLE) * R_GIVE, DIAL_TOP])),
  crown: solid({ x: DX + DIAL_R - 0.6, y: -2.4, w: 4.4, d: 4.8, r: 1.2 }, DIAL_Z + 2, 4, 3),
  crownCap: solid({ x: DX + DIAL_R + 3.6, y: -3.2, w: 2.6, d: 6.4, r: 1.2 }, DIAL_Z + 1.2, 5.6, 3),
  lug: solid({ x: DX - 3, y: DY - DIAL_R - 3.4, w: 6, d: 4, r: 1.2 }, DIAL_Z + 1.5, 5, 3),
  label: topMatrix([DX, DY + 5.2], DIAL_TOP, P, "x"),
};

const REST_GLASS = { x: -60, y: -16, w: 120, d: 32, r: 16 };
export const REST_OUTLINE = planOutline(REST_GLASS, GLASS_TOP, P, 12);
```

## examples/test-rig/live.tsx

```
"use client";

import * as React from "react";
import { IsoFigure } from "../../kit/react/draw";
import { asksForStillness, useReadout } from "../../kit/react/live";
import { frameOf, type Frame } from "./pose";
import { HALF, REST, memberAt, one, poseOf, readoutOf, settled, sizeText, step, type Member, type Pose } from "./rig";
import { EX, FINGER_MOST, GRAB_MOST, HEIGHT, HOVER, PROBE_HOME, PROBE_MAX, PROBE_MIN, WIDTH, at } from "./view";

const FOLLOW = 0.09;
const DOWN = 0.07;
const UP = 0.16;
const NUDGE = 10;
const TOUR_FIRST = 1.4;
const TOUR_BACK = 3600;
const LOOP = 7.6;
const MOST_DT = 1 / 30;

interface Hand {
  held: boolean;
  grab: number;
  finger: number;
}

const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const ease = (t: number) => {
  const u = clamp(t, 0, 1);
  return u * u * (3 - 2 * u);
};

function tour(t: number): Hand {
  const at = t % LOOP;
  if (at < 0.6) return { held: false, grab: PROBE_HOME, finger: PROBE_HOME };
  if (at < 3.7) return { held: true, grab: PROBE_HOME, finger: PROBE_HOME - 80 * ease((at - 1.2) / 1.2) };
  return { held: false, grab: PROBE_HOME, finger: PROBE_HOME - 80 + 80 * ease((at - 4.4) / 1.2) };
}

interface Nodes {
  glass: SVGGElement | null;
  fill: SVGPathElement | null;
  shades: (SVGPathElement | null)[];
  top: SVGPathElement | null;
  crease: SVGPathElement | null;
  bevel: SVGPathElement | null;
  edge: SVGPathElement | null;
  rim: SVGPathElement | null;
  shine: SVGPathElement | null;
  coilsBack: SVGPathElement | null;
  coilsLeft: SVGPathElement | null;
  coilsRightBack: SVGPathElement | null;
  coilsRightFront: SVGPathElement | null;
  carriage: SVGGElement | null;
  plunger: SVGGElement | null;
  needle: SVGPathElement | null;
  pull: SVGPathElement | null;
  grab: SVGCircleElement | null;
  touch: SVGCircleElement | null;
  rider: SVGGElement | null;
  probe: SVGGElement | null;
  arm: SVGGElement | null;
  tip: SVGGElement | null;
  rest: SVGGElement | null;
}

function apply(nodes: Nodes, frame: Frame, held: boolean) {
  const { glass } = frame;
  nodes.fill?.setAttribute("d", glass.fill);
  glass.shades?.forEach((d, index) => nodes.shades[index]?.setAttribute("d", d));
  nodes.top?.setAttribute("d", glass.top);
  nodes.crease?.setAttribute("d", glass.crease);
  nodes.bevel?.setAttribute("d", glass.bevel ?? "");
  nodes.edge?.setAttribute("d", glass.outline);
  nodes.rim?.setAttribute("d", glass.rim);
  nodes.shine?.setAttribute("d", glass.shine);
  if (nodes.glass) {
    if (held) nodes.glass.dataset.lit = "";
    else delete nodes.glass.dataset.lit;
  }
  nodes.coilsBack?.setAttribute("d", frame.coilsBack);
  nodes.coilsLeft?.setAttribute("d", frame.coilsLeft);
  nodes.coilsRightBack?.setAttribute("d", frame.coilsRightBack);
  nodes.coilsRightFront?.setAttribute("d", frame.coilsRightFront);
  nodes.carriage?.setAttribute("transform", frame.carriage);
  nodes.plunger?.setAttribute("transform", frame.carriage);
  nodes.needle?.setAttribute("d", frame.needle);
  nodes.pull?.setAttribute("d", frame.pull);
  for (const [node, point] of [
    [nodes.grab, frame.grab],
    [nodes.touch, frame.finger],
  ] as const) {
    if (!node) continue;
    node.setAttribute("cx", `${point[0]}`);
    node.setAttribute("cy", `${point[1]}`);
    node.style.opacity = frame.pull ? "" : "0";
  }
  nodes.rider?.setAttribute("transform", frame.rider);
  nodes.probe?.setAttribute("transform", frame.probe);
  nodes.arm?.setAttribute("transform", frame.probe);
  nodes.tip?.setAttribute("transform", frame.tip);
}

function restShown(pose: Pose) {
  const off = Math.max(Math.abs(pose.mid - pose.half[0] + HALF[0]), Math.abs(pose.mid + pose.half[0] - HALF[0]), Math.abs(pose.half[1] - HALF[1]));
  return `${Math.round(clamp((off - 0.4) / 1.6, 0, 1) * 100) / 100}`;
}

function spokenOf(member: Member, pull: number, held: boolean) {
  const pose = poseOf(member);
  const size = sizeText(pose).replace(" × ", " by ");
  const state = held ? "Pressed" : member.swell.at === 0 && member.lean.at === 0 ? "At rest" : "Springing back";
  return `${state}: ${size} pixels, pulled ${one(Math.abs(pull))} px, leaning ${one(Math.abs(pose.lean))} px`;
}

const RESTING_TEXT = spokenOf(memberAt(), 0, false);

interface Props {
  label: string;
  base: React.ReactNode;
  rail: React.ReactNode;
  rider: React.ReactNode;
  block: React.ReactNode;
  frameBack: React.ReactNode;
  rods: React.ReactNode;
  carriage: React.ReactNode;
  plunger: React.ReactNode;
  rest: React.ReactNode;
  frameFront: React.ReactNode;
  dial: React.ReactNode;
  finger: React.ReactNode;
  arm: React.ReactNode;
}

export function StretchLive(props: Props) {
  const { anchor, say } = useReadout<HTMLDivElement>();
  const root = React.useRef<HTMLDivElement | null>(null);
  const nodes = React.useRef<Nodes>({
    glass: null,
    fill: null,
    shades: [null, null, null, null],
    top: null,
    crease: null,
    bevel: null,
    edge: null,
    rim: null,
    shine: null,
    coilsBack: null,
    coilsLeft: null,
    coilsRightBack: null,
    coilsRightFront: null,
    carriage: null,
    plunger: null,
    needle: null,
    pull: null,
    grab: null,
    touch: null,
    rider: null,
    probe: null,
    arm: null,
    tip: null,
    rest: null,
  });
  const member = React.useRef<Member>(memberAt());
  const hand = React.useRef<Hand>({ held: false, grab: PROBE_HOME, finger: PROBE_HOME });
  const local = React.useRef(PROBE_HOME);
  const finger = React.useRef(PROBE_HOME);
  const lift = React.useRef(1);
  const frame = React.useRef(0);
  const last = React.useRef(0);
  const clock = React.useRef(0);
  const script = React.useRef<((t: number) => Hand) | null>(null);
  const [pressing, setPressing] = React.useState(false);
  const user = React.useRef(false);
  const [hovering, setHovering] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [visible, setVisible] = React.useState(false);
  const [idle, setIdle] = React.useState(true);
  const [poke, setPoke] = React.useState(0);

  const rest = React.useMemo(() => frameOf(REST, 0, PROBE_HOME, PROBE_HOME, HOVER, PROBE_HOME), []);

  const grip = React.useCallback((next: Hand) => {
    const was = hand.current;
    if (next.held && !was.held) {
      const pose = poseOf(member.current);
      local.current = (next.grab - pose.mid) / (pose.half[0] / HALF[0]);
    }
    hand.current = next;
  }, []);

  const draw = React.useCallback(() => {
    const pose = poseOf(member.current);
    const now = hand.current;
    apply(nodes.current, frameOf(pose, member.current.lean.at, local.current, finger.current, HOVER * lift.current, PROBE_HOME, now.held), now.held);
    say(readoutOf(pose));
    const restNode = nodes.current.rest;
    const shownRest = restShown(pose);
    if (restNode && restNode.style.opacity !== shownRest) restNode.style.opacity = shownRest;
    const pulled = now.held ? finger.current - now.grab : 0;
    const node = root.current;
    if (node) {
      const value = `${Math.round(pulled)}`;
      if (node.getAttribute("aria-valuenow") !== value) node.setAttribute("aria-valuenow", value);
      const spoken = spokenOf(member.current, pulled, now.held);
      if (node.getAttribute("aria-valuetext") !== spoken) node.setAttribute("aria-valuetext", spoken);
    }
  }, [say]);

  const stop = React.useCallback(() => {
    cancelAnimationFrame(frame.current);
    frame.current = 0;
  }, []);

  const run = React.useCallback(() => {
    if (frame.current) return;
    last.current = 0;
    const tick = (now: number) => {
      const dt = last.current ? Math.min((now - last.current) / 1000, MOST_DT) : 1 / 60;
      last.current = now;
      const calm = asksForStillness();
      if (script.current) {
        clock.current += dt;
        grip(script.current(clock.current));
      }
      const want = hand.current;
      const follow = calm ? 1 : 1 - Math.exp(-dt / FOLLOW);
      finger.current += (want.finger - finger.current) * follow;
      if (Math.abs(want.finger - finger.current) < 0.02) finger.current = want.finger;
      const goal = want.held ? 0 : 1;
      const drop = calm ? 1 : 1 - Math.exp(-dt / (want.held ? DOWN : UP));
      lift.current += (goal - lift.current) * drop;
      if (Math.abs(goal - lift.current) < 0.002) lift.current = goal;
      const pulled = want.held ? finger.current - want.grab : 0;
      step(member.current, want.held, pulled, dt, calm);
      draw();
      const done = !script.current && settled(member.current, want.held, pulled) && finger.current === want.finger && lift.current === goal;
      frame.current = done ? 0 : requestAnimationFrame(tick);
    };
    frame.current = requestAnimationFrame(tick);
  }, [draw, grip]);

  const steer = React.useCallback(
    (next: Hand) => {
      script.current = null;
      grip(next);
      run();
    },
    [grip, run],
  );

  React.useEffect(() => {
    const node = root.current;
    if (!node) return;
    const observer = new IntersectionObserver((entries) => setVisible(Boolean(entries.at(-1)?.isIntersecting)));
    observer.observe(node);
    return () => {
      observer.disconnect();
      stop();
    };
  }, [stop]);

  const engaged = hovering || focused || pressing;

  React.useEffect(() => {
    if (engaged) {
      setIdle(false);
      return;
    }
    const timer = window.setTimeout(() => setIdle(true), TOUR_BACK);
    return () => window.clearTimeout(timer);
  }, [engaged, poke]);

  const touring = visible && idle && !engaged;

  React.useEffect(() => {
    if (!visible) {
      script.current = null;
      stop();
      return;
    }
    if (touring && !asksForStillness()) {
      const timer = window.setTimeout(
        () => {
          clock.current = 0;
          script.current = tour;
          run();
        },
        TOUR_FIRST * 1000,
      );
      return () => {
        window.clearTimeout(timer);
        script.current = null;
      };
    }
    script.current = null;
    if (user.current) {
      run();
      return;
    }
    steer({ held: false, grab: hand.current.grab, finger: hovering ? hand.current.finger : PROBE_HOME });
  }, [visible, touring, hovering, run, steer, stop]);

  const xAt = (clientX: number) => {
    const svg = root.current?.querySelector("svg");
    const box = svg?.getBoundingClientRect();
    if (!box || !box.width) return null;
    const sx = ((clientX - box.left) / box.width) * WIDTH;
    const origin = at([0, 0, 0]);
    return (sx - origin[0]) / EX[0];
  };

  const wake = () => {
    setIdle(false);
    setPoke((count) => count + 1);
  };

  const hoverTo = (x: number) => steer({ held: false, grab: hand.current.grab, finger: clamp(x, PROBE_MIN, PROBE_MAX) });

  const hold = (on: boolean) => {
    user.current = on;
    setPressing(on);
  };

  const pressAt = (x: number) => {
    hold(true);
    const grab = clamp(x, -GRAB_MOST, GRAB_MOST);
    steer({ held: true, grab, finger: grab });
  };

  const pullTo = (x: number) => steer({ held: true, grab: hand.current.grab, finger: clamp(x, -FINGER_MOST, FINGER_MOST) });

  const release = () => {
    hold(false);
    steer({ held: false, grab: hand.current.grab, finger: hand.current.finger });
  };

  const bind = <K extends keyof Nodes>(key: K) => (node: Nodes[K]) => {
    nodes.current[key] = node;
  };

  const shown = rest.glass;

  return (
    <div
      ref={(node) => {
        root.current = node;
        anchor.current = node;
      }}
      className="ms"
      role="slider"
      tabIndex={0}
      aria-label="Glass button on a test rig: Space presses or lets go, arrow keys move the finger and pull"
      aria-valuemin={-FINGER_MOST - GRAB_MOST}
      aria-valuemax={FINGER_MOST + GRAB_MOST}
      aria-valuenow={0}
      aria-valuetext={RESTING_TEXT}
      data-held={pressing ? "" : undefined}
      onPointerEnter={(event) => {
        if (event.pointerType === "mouse") setHovering(true);
      }}
      onPointerMove={(event) => {
        const x = xAt(event.clientX);
        if (x === null) return;
        if (user.current && event.currentTarget.hasPointerCapture(event.pointerId)) {
          wake();
          pullTo(x);
        } else if (event.pointerType === "mouse" && !user.current) {
          wake();
          hoverTo(x);
        }
      }}
      onPointerDown={(event) => {
        const x = xAt(event.clientX);
        if (x === null) return;
        event.currentTarget.setPointerCapture(event.pointerId);
        wake();
        pressAt(x);
      }}
      onPointerUp={(event) => {
        if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
        if (user.current) release();
      }}
      onPointerCancel={() => {
        if (user.current) release();
      }}
      onPointerLeave={(event) => {
        if (event.pointerType !== "mouse" || user.current) return;
        setHovering(false);
        hoverTo(PROBE_HOME);
      }}
      onFocus={() => setFocused(true)}
      onBlur={() => {
        setFocused(false);
        if (user.current) release();
      }}
      onKeyDown={(event) => {
        const now = hand.current;
        const by = event.shiftKey ? NUDGE * 3 : NUDGE;
        if (event.key === " " || event.key === "Enter") {
          event.preventDefault();
          if (event.repeat) return;
          wake();
          if (user.current) release();
          else pressAt(now.finger);
          return;
        }
        let x: number | null = null;
        if (event.key === "ArrowLeft" || event.key === "ArrowDown") x = now.finger - by;
        else if (event.key === "ArrowRight" || event.key === "ArrowUp") x = now.finger + by;
        else if (event.key === "Home") x = -Infinity;
        else if (event.key === "End") x = Infinity;
        else if (event.key === "Escape") {
          if (user.current) release();
          return;
        }
        if (x === null) return;
        event.preventDefault();
        wake();
        if (user.current) pullTo(x);
        else hoverTo(x);
      }}
    >
      <IsoFigure width={WIDTH} height={HEIGHT} label={props.label}>
        {props.base}
        <g>
          <g>
            {props.rail}
            <g ref={bind("rider")} transform={rest.rider}>
              {props.rider}
            </g>
            <g ref={bind("probe")} transform={rest.probe}>
              {props.block}
            </g>
          </g>
        </g>
        {props.frameBack}
        <g>
          <g>
            <path ref={bind("coilsBack")} className="iso-line ms-coil" data-tone="lo" d={rest.coilsBack} />
            {props.rods}
            <path ref={bind("coilsLeft")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsLeft} />
            <g ref={bind("carriage")} transform={rest.carriage}>
              {props.carriage}
            </g>
          </g>
        </g>
        <g>
          <g ref={bind("glass")} className="iso-solid ms-glass">
            <path ref={bind("fill")} className="iso-fill" d={shown.fill} />
            {[0, 1, 2, 3].map((index) => (
              <path
                key={index}
                ref={(node) => {
                  nodes.current.shades[index] = node;
                }}
                className="iso-shade"
                data-shade={index}
                d={shown.shades?.[index] ?? ""}
              />
            ))}
            <path ref={bind("top")} className="iso-top" d={shown.top} />
            <path ref={bind("crease")} className="iso-line" data-tone="lo" d={shown.crease} />
            <path ref={bind("bevel")} className="iso-line iso-bevel" data-tone="lo" d={shown.bevel ?? ""} />
            <path ref={bind("rim")} className="iso-line ms-rim" data-tone="mid" d={shown.rim} />
            <path ref={bind("shine")} className="iso-line ms-shine" data-tone="hi" d={shown.shine} />
            <path ref={bind("edge")} className="iso-line iso-edge" data-tone="hi" d={shown.outline} />
          </g>
        </g>
        <g>
          <g ref={bind("rest")} style={{ opacity: 0 }}>
            {props.rest}
          </g>
        </g>
        <g>
          <g className="iso-wire ms-pull" data-tone="hi">
            <path ref={bind("pull")} className="iso-line" data-tone="lit" d={rest.pull} />
            <circle ref={bind("grab")} className="iso-wire-end" cx={rest.grab[0]} cy={rest.grab[1]} r={1.2} style={{ opacity: 0 }} />
            <circle ref={bind("touch")} className="iso-wire-end" cx={rest.finger[0]} cy={rest.finger[1]} r={1.2} style={{ opacity: 0 }} />
          </g>
        </g>
        <g>
          <path ref={bind("coilsRightBack")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsRightBack} />
        </g>
        <g>
          <g ref={bind("plunger")} transform={rest.carriage}>
            {props.plunger}
          </g>
        </g>
        <g>
          <path ref={bind("coilsRightFront")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsRightFront} />
        </g>
        {props.frameFront}
        <g>
          <g>
            {props.dial}
            <path ref={bind("needle")} className="iso-line ms-needle" data-tone="hi" d={rest.needle} />
          </g>
        </g>
        <g>
          <g ref={bind("tip")} transform={rest.tip}>
            {props.finger}
          </g>
        </g>
        <g>
          <g ref={bind("arm")} transform={rest.probe}>
            {props.arm}
          </g>
        </g>
      </IsoFigure>
    </div>
  );
}
```

## examples/test-rig/pose.ts

```ts
import { insetPlan, onTop, pathOf, planOutline, slabOf, type Flat, type SolidPaths } from "../../kit/iso-kit";
import { carried, type Pose } from "./rig";
import { AZIMUTH, BUSH_END, COIL_R, COIL_TURNS, DIAL, DIAL_SPAN, DIAL_TOP, DIAL_R, FRAME_X, GLASS_H, GLASS_TOP, GLASS_Z, P, ROD_Y, ROD_Z, at, shift } from "./view";

export interface GlassPaths extends SolidPaths {
  rim: string;
  shine: string;
}

export interface Frame {
  glass: GlassPaths;
  coilsBack: string;
  coilsLeft: string;
  coilsRightBack: string;
  coilsRightFront: string;
  carriage: string;
  needle: string;
  grab: Flat;
  finger: Flat;
  pull: string;
  rider: string;
  probe: string;
  tip: string;
}

const RADIANS = Math.PI / 180;
const SIN_A = Math.sin(AZIMUTH * RADIANS);
const COS_E = Math.cos(30 * RADIANS);
const SIN_E = Math.sin(30 * RADIANS);
const PER_TURN = 16;
const tenth = (value: number) => Math.round(value * 10) / 10;

export function glassOf(pose: Pose): GlassPaths {
  const [hx, hy] = pose.half;
  const plan = { x: pose.mid - hx, y: -hy, w: hx * 2, d: hy * 2, r: hy };
  const paths = slabOf(plan, GLASS_Z, GLASS_H, P, 10, 1.2);
  const inner = insetPlan(plan, 3.2);
  const shine = [
    onTop([[pose.mid - hx * 0.62, -hy + 5.4], [pose.mid + hx * 0.18, -hy + 5.4]], GLASS_TOP, P),
    onTop([[pose.mid + hx * 0.3, -hy + 5.4], [pose.mid + hx * 0.42, -hy + 5.4]], GLASS_TOP, P),
  ].join("");
  return { ...paths, rim: planOutline(inner, GLASS_TOP, P, 10), shine };
}

function coil(from: number, to: number, y: number) {
  const count = COIL_TURNS * PER_TURN;
  const back: Flat[][] = [];
  const front: Flat[][] = [];
  let run: Flat[] = [];
  let facing: boolean | null = null;
  for (let index = 0; index <= count; index++) {
    const share = index / count;
    const angle = share * COIL_TURNS * Math.PI * 2;
    const cy = Math.cos(angle);
    const cz = Math.sin(angle);
    const point = at([from + (to - from) * share, y + COIL_R * cy, ROD_Z + COIL_R * cz]);
    const near = cy * SIN_A * COS_E + cz * SIN_E > 0;
    if (facing === null) facing = near;
    if (near !== facing) {
      run.push(point);
      (facing ? front : back).push(run);
      run = [point];
      facing = near;
      continue;
    }
    run.push(point);
  }
  if (run.length > 1) (facing ? front : back).push(run);
  return { back: back.map((points) => pathOf(points)).join(""), front: front.map((points) => pathOf(points)).join("") };
}

function needleOf(lean: number) {
  const angle = (-lean / DIAL_SPAN) * Math.PI * 2;
  const ux = Math.sin(angle);
  const uy = -Math.cos(angle);
  const [cx, cy] = DIAL;
  const point = (along: number, across: number) => at([cx + ux * along - uy * across, cy + uy * along + ux * across, DIAL_TOP + 0.1]);
  return pathOf([point(DIAL_R - 3.4, 0), point(0, 0.75), point(-3, 0.35), point(-3, -0.35), point(0, -0.75)], true);
}

export function frameOf(pose: Pose, lean: number, grab: number, finger: number, lift: number, home: number, held = false): Frame {
  const left = ROD_Y;
  const coils = [
    coil(-FRAME_X, -BUSH_END + lean, -left),
    coil(-FRAME_X, -BUSH_END + lean, left),
    coil(BUSH_END + lean, FRAME_X, -left),
    coil(BUSH_END + lean, FRAME_X, left),
  ];
  const grabX = carried(pose, grab);
  const grabAt = at([grabX, 0, GLASS_TOP]);
  const fingerAt = at([finger, 0, GLASS_TOP + lift]);
  return {
    glass: glassOf(pose),
    coilsBack: coils.map((one) => one.back).join(""),
    coilsLeft: coils[0]!.front + coils[1]!.front,
    coilsRightBack: coils[2]!.front,
    coilsRightFront: coils[3]!.front,
    carriage: shift(lean),
    needle: needleOf(lean),
    grab: [tenth(grabAt[0]), tenth(grabAt[1])],
    finger: [tenth(fingerAt[0]), tenth(fingerAt[1])],
    pull: !held || Math.abs(grabX - finger) < 0.6 ? "" : pathOf([grabAt, fingerAt]),
    rider: shift(grab),
    probe: shift(finger - home),
    tip: shift(finger - home, lift),
  };
}
```

## examples/test-rig/rig.ts

```ts
export type Pair = [number, number];
export type Tuning = readonly [number, number];

export interface Spring {
  at: number;
  rate: number;
}

export interface Member {
  swell: Spring;
  lean: Spring;
}

export interface Pose {
  mid: number;
  half: Pair;
  lean: number;
  press: number;
}

export const HALF: Pair = [60, 16];
export const SWELL = 0.08;
export const SWELL_MOST = 6;
export const SWELL_FLOOR = -0.25;
export const RUBBER = 0.55;
const STRETCH = 0.35;
export const STRETCH_MOST = 0.08;
const THIN = 0.5;
const SOFT = 1;
export const GIVE = 14;
export const REACH = 0.15;
export const GIVE_MOST = 0.25;
const QUIET = 0.0005;
const SUB_RATE = 240;
export const FRAME = 1 / 60;

export const PRESS_SPRING: Tuning = [700, 32];
export const HOLD_SPRING: Tuning = [540, 40];
export const RELEASE_SPRING: Tuning = [320, 16];

const LONGEST = Math.max(HALF[0], HALF[1]);
export const SHARE = Math.min(SWELL, SWELL_MOST / LONGEST);
export const GIVEN = Math.min(GIVE, REACH * 2 * LONGEST);
export const ACROSS = (GIVEN * HALF[1]) / LONGEST;

export const zeta = ([stiff, drag]: Tuning) => drag / (2 * Math.sqrt(stiff));

function spring(held: Spring, want: number, dt: number, [stiff, drag]: Tuning, calm: boolean) {
  const damping = calm ? 2 * Math.sqrt(stiff) : drag;
  const steps = Math.max(Math.ceil(dt * SUB_RATE), 1);
  const slice = dt / steps;
  for (let step = 0; step < steps; step++) {
    held.rate += (want - held.at) * stiff * slice - held.rate * damping * slice;
    held.at += held.rate * slice;
  }
  if (Math.abs(want - held.at) < QUIET && Math.abs(held.rate) < QUIET) {
    held.at = want;
    held.rate = 0;
  }
}

export function rubberBand(distance: number, limit: number) {
  return limit <= 0 ? 0 : limit * (1 - 1 / (1 + (RUBBER * distance) / limit));
}

const softened = (value: number) => Math.hypot(value, SOFT) - SOFT;

function stretched(lean: number, half: number, grow: number) {
  const most = STRETCH_MOST * grow * half;
  return most * Math.tanh((STRETCH * softened(lean)) / most);
}

export const grownOf = (member: Member) => 1 + SHARE * Math.max(member.swell.at, SWELL_FLOOR);

export const memberAt = (swell = 0, lean = 0): Member => ({ swell: { at: swell, rate: 0 }, lean: { at: lean, rate: 0 } });

export function step(member: Member, held: boolean, pull: number, dt: number, calm = false) {
  spring(member.swell, held ? 1 : 0, dt, held ? PRESS_SPRING : RELEASE_SPRING, calm);
  const want = held && !calm ? Math.sign(pull) * rubberBand(Math.abs(pull), GIVEN) : 0;
  spring(member.lean, want, dt, held ? HOLD_SPRING : RELEASE_SPRING, calm);
}

export function poseOf(member: Member): Pose {
  const grow = grownOf(member);
  const lean = member.lean.at;
  const stretch = stretched(lean, HALF[0], grow);
  const ratio = 1 + stretch / (grow * HALF[0]);
  return {
    mid: lean - Math.sign(lean) * stretch,
    half: [grow * HALF[0] * ratio, grow * HALF[1] * Math.pow(ratio, -THIN)],
    lean,
    press: Math.min(Math.max(member.swell.at, 0), 1.2),
  };
}

export const carried = (pose: Pose, x: number) => pose.mid + (pose.half[0] / HALF[0]) * x;

export const settled = (member: Member, held: boolean, pull: number) =>
  member.swell.rate === 0 && member.lean.rate === 0 && member.swell.at === (held ? 1 : 0) && (held || member.lean.at === 0) && (held || pull === 0);

export const one = (value: number) => {
  const text = (Math.round(value * 10) / 10).toFixed(1);
  return text === "-0.0" ? "0.0" : text;
};

export const two = (value: number) => (Math.round(value * 100) / 100).toFixed(2);

export const sizeText = (pose: Pose) => `${one(pose.half[0] * 2)} × ${one(pose.half[1] * 2)}`;

export function steady(pull: number) {
  const member = memberAt();
  for (let frame = 0; frame < 360; frame++) step(member, true, pull, FRAME);
  return poseOf(member);
}

function pressRun() {
  const member = memberAt();
  let peak = 0;
  let peakAt = 0;
  for (let frame = 1; frame <= 120; frame++) {
    step(member, true, 0, FRAME);
    const tall = poseOf(member).half[1] * 2;
    if (tall > peak) {
      peak = tall;
      peakAt = frame * FRAME;
    }
  }
  return { peak, peakAt };
}

export const STILL_ENOUGH = 0.1;

function releaseRun(pull: number) {
  const member = memberAt();
  for (let frame = 0; frame < 360; frame++) step(member, true, pull, FRAME);
  let swing = 0;
  let swingAt = 0;
  let dip = 0;
  let still = 0;
  for (let frame = 1; frame <= 300; frame++) {
    step(member, false, 0, FRAME);
    const lean = member.lean.at;
    if (-Math.sign(pull) * lean > swing) {
      swing = -Math.sign(pull) * lean;
      swingAt = frame * FRAME;
    }
    dip = Math.max(dip, (1 - grownOf(member)) * 100);
    const pose = poseOf(member);
    const edges = [pose.mid - pose.half[0], pose.mid + pose.half[0], -pose.half[1], pose.half[1]];
    const rest = [-HALF[0], HALF[0], -HALF[1], HALF[1]];
    const off = Math.max(...edges.map((edge, index) => Math.abs(edge - rest[index]!)));
    if (off > STILL_ENOUGH) still = (frame + 1) * FRAME;
  }
  return { swing, swingAt, dip, still };
}

export const DRAG = 80;
export const PULL_MOST = 110;
export const REST: Pose = { mid: 0, half: [HALF[0], HALF[1]], lean: 0, press: 0 };
export const PRESSED = steady(0);
export const PULLED = steady(-DRAG);
export const PULLED_MOST = steady(-PULL_MOST);
export const PRESS_RUN = pressRun();
export const RELEASE_RUN = releaseRun(-DRAG);
export const DIP_MOST = -SWELL_FLOOR * SHARE * 100;
export const SIDE_GROWTH = SHARE * HALF[0];

export function readoutOf(pose: Pose) {
  return `${sizeText(pose)} · lean ${one(Math.abs(pose.lean))} px`;
}
```

## examples/test-rig/stretch.css

```

```

## examples/test-rig/view.ts

```ts
import { iso, type Flat, type Plan, type Point3, type Projection } from "../../kit/iso-kit";

export const WIDTH = 600;
export const HEIGHT = 340;
const PAD = 34;
export const AZIMUTH = 57;

export const BASE: Plan = { x: -134, y: -82, w: 298, d: 150, r: 9 };
export const BASE_H = 9;
export const FOOT_H = 5;

export const FRAME_X = 101;
export const FRAME_Y = 37;
export const BAR = 9;
export const BAR_H = 12;

export const ROD_Y = 18;
export const ROD_Z = 6;
export const ROD_HALF = 2;
export const COIL_R = 4.2;
export const COIL_TURNS = 6;

export const CARRIAGE: Plan = { x: -72, y: -26, w: 144, d: 52, r: 4 };
export const CARRIAGE_Z = 3;
export const CARRIAGE_H = 5;
export const CARRIAGE_TOP = CARRIAGE_Z + CARRIAGE_H;
export const BUSH_END = 80;
export const BUSH_W = 14;
export const BUSH_D = 12;

export const GLASS_Z = CARRIAGE_TOP;
export const GLASS_H = 10;
export const GLASS_TOP = GLASS_Z + GLASS_H;

export const PLUNGER_Z = 6;
export const DIAL: Flat = [131, 0];
export const DIAL_R = 15;
export const DIAL_Z = 3;
export const DIAL_H = 8;
export const DIAL_TOP = DIAL_Z + DIAL_H;
export const DIAL_SPAN = 20;

export const RAIL_Y = -66;
export const RAIL_D = 8;
export const RAIL_Z = 50;
export const RAIL_H = 8;
export const POST_L = -124;
export const POST_R = 66;
export const POST = 12;

export const PROBE_HOME = 45;
export const PROBE_MIN = -98;
export const PROBE_MAX = 48;
export const HOUSING_R = 5.5;
export const HOUSING_Z = 44;
export const HOUSING_H = 20;
export const PAD_R = 4.4;
export const PAD_H = 3;
export const HOVER = 12;
export const STEM = 28;

export const GRAB_MOST = 52;
export const FINGER_MOST = 62;

function fit(points: Point3[]): Projection {
  const unit: Projection = { origin: [0, 0], scale: 1, azimuth: AZIMUTH };
  const flat = points.map((point) => iso(point, unit));
  const xs = flat.map(([x]) => x);
  const ys = flat.map(([, y]) => y);
  const left = Math.min(...xs);
  const right = Math.max(...xs);
  const up = Math.min(...ys);
  const down = Math.max(...ys);
  const scale = Math.min((WIDTH - PAD * 2) / (right - left), (HEIGHT - PAD * 2) / (down - up));
  return { origin: [WIDTH / 2 - ((left + right) / 2) * scale, HEIGHT / 2 - ((up + down) / 2) * scale], scale, azimuth: AZIMUTH };
}

const corners = ({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] => {
  const out: Point3[] = [];
  for (const px of [x, x + w]) for (const py of [y, y + d]) for (const z of [z0, z1]) out.push([px, py, z]);
  return out;
};

export const P = fit([
  ...corners(BASE, -BASE_H - FOOT_H, 0),
  ...corners({ x: POST_L, y: RAIL_Y - RAIL_D / 2, w: POST_R + POST - POST_L, d: RAIL_D, r: 0 }, RAIL_Z, RAIL_Z + RAIL_H + 6),
]);

export const at = (point: Point3) => iso(point, P);

const ORIGIN = at([0, 0, 0]);
const minus = (a: Flat, b: Flat): Flat => [a[0] - b[0], a[1] - b[1]];
export const EX = minus(at([1, 0, 0]), ORIGIN);
export const EZ = minus(at([0, 0, 1]), ORIGIN);

const fine = (value: number) => Math.round(value * 100) / 100;

export const shift = (x: number, z = 0) => `translate(${fine(EX[0] * x + EZ[0] * z)} ${fine(EX[1] * x + EZ[1] * z)})`;
```

## examples/turning-dial

```

```

## examples/turning-dial/build.mjs

```js
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import * as G from "../../kit/lathe.mjs";
import * as A from "../../kit/audit.mjs";
import { turning } from "../../kit/turn-build.mjs";
import * as TA from "../../kit/turn-audit.mjs";
import { turnScript, glScript } from "../../scripts/inline-kit.mjs";
import { glCamera } from "../../kit/gl.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const theme = argv.includes("--light") ? "light" : "dark";

const WIDTH = 640;
const HEIGHT = 470;
const AZIMUTH = 40;
const ELEVATION = 31;

const BASE = { x: -82, y: -78, w: 164, d: 156, r: 8 };
const BASE_H = 12;
const FOOT_H = 5;
const PLATE = { x: -72, y: -68, w: 144, d: 136, r: 6 };
const PLATE_Z0 = 8;
const PLATE_Z1 = 10;
const DIAL_R = 50;
const SKIRT_TOP = 14.4;
const FACE_Z = 17.6;
const FACE_R = 46;
const HUB_R = 12;
const HUB_TOP = 19.4;
const PALM = { x: -18.5, y: -16.5, w: 30, d: 33, r: 3.2 };
const PALM_TOP = HUB_TOP + 8;
const COVER = { x: -15.5, y: -13.2, w: 26.6, d: 26.4, r: 2.6 };
const COVER_TOP = PALM_TOP + 1.2;
const INDEX_AT = ((AZIMUTH + 180) * Math.PI) / 180;

export const P = k.fitProjection([...k.boxCorners(BASE, -BASE_H - FOOT_H, 0), [0, 0, 40]], WIDTH, HEIGHT, { pad: 22, azimuth: AZIMUTH, elevation: ELEVATION });
const at = (p) => k.iso(p, P);
const S = (paths, style) => k.solidSvg(paths, style);
const L = (d, style) => k.lineSvg(d, style);
const D = (points, style) => k.dotsSvg(points, style);
const VERT = (x, y) => G.frameOf([x, y, 0], [0, 0, 1], [1, 0, 0], [0, 1, 0]);
const byDepth = (points) => [...points].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P));

export const R = A.recorder(P);
const put = (item) => R.put({ ...item, shapes: item.shapes.map((shape, index) => R.solid(Object.assign(shape, { name: shape.name ?? (index ? `${item.name}#${index + 1}` : item.name) }))) });

function base() {
  for (const [x, y] of k.corners(BASE, 22)) {
    const svg = S(k.cylinder(x, y, 7, -BASE_H - FOOT_H, 1.6, P, 28), { tone: "lo" }) + S(k.cylinder(x, y, 5.4, -BASE_H - FOOT_H + 1.6, FOOT_H - 1.6, P, 28), { tone: "mid" });
    put({ name: `stand.foot.${x}.${y}`, layer: "back", at: [x, y, -BASE_H - FOOT_H], svg, shapes: [A.cylinder(x, y, 7, -BASE_H - FOOT_H, 1.6, { ground: true }), A.cylinder(x, y, 5.4, -BASE_H - FOOT_H + 1.6, FOOT_H - 1.6)] });
  }
  const screws = k.corners(BASE, 7);
  const svg = S(k.slabOf(BASE, -BASE_H, BASE_H, P, 8, 1.6), { tone: "mid", inner: L(k.sideSeam(BASE, -BASE_H + 4, P, 8), { tone: "faint" }) }) + L(screws.map(([x, y]) => k.ring(x, y, 2.1, 0, P, 16)).join(""), { tone: "lo" }) + D(screws.map(([x, y]) => at([x, y, 0])), { size: 0.5 });
  put({ name: "stand.base", layer: "back", at: [0, 0, -BASE_H], bias: 4, svg, shapes: [A.slab(BASE, -BASE_H, BASE_H, { ground: true })] });
}

const WHEEL_AT = [40 * Math.cos((AZIMUTH * Math.PI) / 180), 40 * Math.sin((AZIMUTH * Math.PI) / 180)];
const WHEEL_R = 32;
const PINION_R = 8;
const LOCK_Z0 = 3.2;
const LOCK_Z1 = 7.2;
const WINDOW = { x: 42.4, y: 35.6, w: 12.6, d: 10.4, r: 2.2 };

function backPosts() {
  const out = [];
  const V = [Math.cos((AZIMUTH * Math.PI) / 180), Math.sin((AZIMUTH * Math.PI) / 180)];
  for (const [x, y] of byDepth(k.corners(PLATE, 12))) {
    const front = x * V[0] + y * V[1] > 0;
    const svg = S(k.cylinder(x, y, 3.4, 0, PLATE_Z0, P, 28), { tone: "mid", crease: "none" });
    put({ name: `stand.post.${x}.${y}`, layer: front ? "mid" : "back", at: [x, y, 0], svg, shapes: [A.cylinder(x, y, 3.4, 0, PLATE_Z0)] });
  }
  for (const [name, [x, y], r] of [["stand.washer.dial", [0, 0], 4.2], ["stand.washer.wheel", WHEEL_AT, 6.4]]) {
    const svg = S(k.cylinder(x, y, r, 0, LOCK_Z0, P, 32), { tone: "lo" });
    put({ name, layer: "back", at: [x, y, 0], svg, shapes: [A.cylinder(x, y, r, 0, LOCK_Z0)] });
  }
}

function windowed() {
  const slab = k.slabOf(PLATE, PLATE_Z0, PLATE_Z1 - PLATE_Z0, P, 8, 1);
  const ring = k.roundedPlan(WINDOW, 4);
  const hole = k.onTop(ring, PLATE_Z1, P, true);
  const V = [Math.cos((AZIMUTH * Math.PI) / 180), Math.sin((AZIMUTH * Math.PI) / 180)];
  const lx = -Math.sin((AZIMUTH * Math.PI) / 180);
  const ly = Math.cos((AZIMUTH * Math.PI) / 180);
  const n = ring.length;
  const walls = [[], [], [], []];
  const bottoms = [];
  for (let i = 0; i < n; i++) {
    const a = ring[i];
    const b = ring[(i + 1) % n];
    const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    const nx = -(b[1] - a[1]) / len;
    const ny = (b[0] - a[0]) / len;
    if (nx * V[0] + ny * V[1] <= 1e-9) continue;
    const shade = Math.min(3, Math.max(0, Math.floor(((nx * lx + ny * ly + 1) / 2) * 4)));
    walls[shade].push(k.pathOf([at([a[0], a[1], PLATE_Z1]), at([b[0], b[1], PLATE_Z1]), at([b[0], b[1], PLATE_Z0]), at([a[0], a[1], PLATE_Z0])], true));
    bottoms.push([a, b]);
  }
  const clip = `<clipPath id="td-window"><path d="${hole}"/></clipPath>`;
  const wallSvg = walls.map((list, shade) => (list.length ? `<path class="iso-shade" data-shade="${shade}" d="${list.join("")}"/>` : "")).join("");
  const under = bottoms.map(([a, b]) => k.pathOf([at([a[0], a[1], PLATE_Z0]), at([b[0], b[1], PLATE_Z0])])).join("");
  const screws = byDepth(k.corners(PLATE, 12));
  const inner = [
    `<g clip-path="url(#td-window)">${wallSvg}${L(under, { tone: "lo" })}</g>`,
    L(k.planOutline(k.insetPlan(PLATE, 4), PLATE_Z1, P), { tone: "faint" }),
    L(screws.map(([x, y]) => k.ring(x, y, 2.4, PLATE_Z1, P, 18)).join(""), { tone: "lo" }),
    L(k.planOutline(k.insetPlan(WINDOW, -1.4), PLATE_Z1, P, 4), { tone: "faint" }),
  ].join("");
  const body = `<defs>${clip}</defs><g class="iso-solid"><path class="iso-fill" fill-rule="evenodd" d="${slab.fill}${hole}"/>${slab.shades.map((d, shade) => (d ? `<path class="iso-shade" data-shade="${shade}" d="${d}"/>` : "")).join("")}<path class="iso-top" fill-rule="evenodd" d="${slab.top}${hole}"/><path class="iso-line iso-crease" data-tone="faint" d="${slab.crease}"/><path class="iso-line iso-bevel" data-tone="faint" d="${slab.bevel}"/>${inner}<path class="iso-line iso-edge" data-tone="mid" d="${slab.outline}${hole}"/></g>`;
  const dots = D(screws.map(([x, y]) => at([x, y, PLATE_Z1])), { size: 0.5 });
  return `<path class="iso-halo" d="${k.haloOf(k.insetPlan(PLATE, 4), PLATE_Z0, 0.1, P)}" style="filter:blur(5px)"/>${body}${dots}`;
}

function midStatic() {
  const svg = windowed();
  const corners = k.roundedPlan(PLATE, 8);
  void corners;
  put({ name: "stand.plate", layer: "mid", at: [0, 0, PLATE_Z0], svg, shapes: [A.slab(PLATE, PLATE_Z0, PLATE_Z1 - PLATE_Z0)] });
  const c = Math.cos(INDEX_AT);
  const s = Math.sin(INDEX_AT);
  const wedge = [[DIAL_R + 1.2, -0.7], [DIAL_R + 1.2, 0.7], [DIAL_R + 7.4, 2.6], [DIAL_R + 7.4, -2.6]].map(([r, t]) => [c * r - s * t, s * r + c * t]);
  const wedgeSvg = S(k.extrude(wedge, PLATE_Z1, 3, P, { bevel: 0.35 }), { tone: "hi", lit: true });
  put({ name: "stand.index", layer: "mid", at: [c * 48, s * 48, PLATE_Z1], svg: `<g data-mat="brass">${wedgeSvg}</g>`, shapes: [A.extruded(wedge, PLATE_Z1, 3)] });
}


export const T = turning(P, { recorder: R });
const dial = T.group("dial", { turn: { pivot: [0, 0] } });
const wheel = T.group("wheel", { turn: { pivot: WHEEL_AT } });
const PAWL_AT = [0, DIAL_R + 8.3];
const pawl = T.group("pawl", { hinge: { point: [PAWL_AT[0], PAWL_AT[1], 0], axis: [0, 0, 1], range: [-4, 4] } });
const lock = T.layer("lock");
const face = T.layer("dial");
const hand = T.layer("hand");
T.stack(["back", "lock", "mid", "dial", "hand", "front"]);
export const follow = (values) => ({ ...values, wheel: -(values.dial ?? 0) / 4, pawl: 4 * Math.sin(((values.dial ?? 0) * Math.PI) / 18), "f2.mcp": -22 * (values.lift ?? 0), "f2.pip": -14 * (values.lift ?? 0), "f2.dip": -9 * (values.lift ?? 0) });
export const tapAt = (time) => {
  const phase = ((time % 4) + 4) % 4;
  const up = Math.min(1, Math.max(0, (phase - 0.4) / 0.9));
  const down = Math.min(1, Math.max(0, (phase - 2.2) / 1.1));
  const ease = (x) => x * x * (3 - 2 * x);
  return ease(up) * (1 - ease(down));
};
export const breakAt = (time) => {
  if (!(time > 0)) return null;
  const flight = 0.79888;
  const c = 1.0;
  let phi = 0;
  let psi = 0;
  let y = 0;
  let z = 0;
  if (time < flight) {
    const s = time / flight;
    phi = 2 * Math.PI * s * s * (3 - 2 * s);
    y = 16 * time;
    z = 25 * time - 49 * time * time;
  } else {
    const k = 1 - Math.exp(-(time - flight) / 0.25);
    phi = 2 * Math.PI;
    y = 16 * flight + 1.5 * k;
    z = -11.3;
    psi = (40 * Math.PI * k) / 180;
  }
  const cp = Math.cos(phi);
  const sp = Math.sin(phi);
  const cz = Math.cos(psi);
  const sz = Math.sin(psi);
  const R = [cz, -sz * cp, sz * sp, sz, cz * cp, -cz * sp, 0, sp, cp];
  return { R, t: [-R[2] * c, y - R[5] * c, z + c - R[8] * c] };
};
export const BREAK_END = 2.2;
export const poses = () => {
  const out = Array.from({ length: 1440 }, (_, deg) => follow({ dial: deg, lift: tapAt(deg / 30) }));
  for (let start = 0; start < 360; start += 45) for (let f = 0; f <= 66; f++) out.push(follow({ dial: start + f * 0.6, lift: tapAt(f / 30), pod: breakAt(f / 30) }));
  return out;
};

const DIAL_PROFILE = [[PLATE_Z1, DIAL_R], [SKIRT_TOP, DIAL_R], [FACE_Z, FACE_R]];
const DIAL_F = VERT(0, 0);
const tick = (angle, from, to) => `M${(Math.sin(angle) * from).toFixed(3)} ${(-Math.cos(angle) * from).toFixed(3)}L${(Math.sin(angle) * to).toFixed(3)} ${(-Math.cos(angle) * to).toFixed(3)}`;
const ringPath = (r, steps) => Array.from({ length: steps }, (_, j) => `${j ? "L" : "M"}${(r * Math.cos((j / steps) * Math.PI * 2)).toFixed(3)} ${(r * Math.sin((j / steps) * Math.PI * 2)).toFixed(3)}`).join("") + "Z";
const faceMarkup = () => {
  const rim = FACE_R - 0.8;
  const minor = [];
  const major = [];
  for (let i = 0; i < 100; i++) {
    const angle = (i / 100) * Math.PI * 2;
    (i % 10 ? minor : major).push(tick(angle, rim - (i % 10 ? 1.4 : 2.6), rim));
  }
  const numerals = Array.from({ length: 10 }, (_, i) => {
    const angle = (i / 10) * Math.PI * 2;
    const r = rim - 5.4;
    return `<text class="iso-face-text" data-tone="lo" font-size="3.4" text-anchor="middle" transform="translate(${(Math.sin(angle) * r).toFixed(3)} ${(-Math.cos(angle) * r).toFixed(3)}) rotate(${(i * 36).toFixed(1)}) translate(0 1.2)">${i * 10}</text>`;
  }).join("");
  return `<path class="iso-line" data-tone="lo" d="${ringPath(rim, 160)}"/><path class="iso-line" data-tone="lo" d="${minor.join("")}"/><path class="iso-line" data-tone="mid" d="${major.join("")}"/>${numerals}<path class="iso-line" data-tone="faint" d="${ringPath(33.5, 140)}"/>`;
};
const dialBody = T.round({ name: "dial.body", group: dial, layer: face, F: DIAL_F, s0: PLATE_Z1, s1: SKIRT_TOP, r: DIAL_R, tone: "hi", crease: "lo", material: "steel", details: { ribs: { s0: PLATE_Z1 + 0.9, s1: SKIRT_TOP - 0.7, count: 150, fade: [0.08, 0.5], seams: true, tone: "lo" } } });
const dialBevel = T.round({ name: "dial.bevel", group: dial, layer: face, F: DIAL_F, s0: SKIRT_TOP, s1: FACE_Z, r: [DIAL_R, FACE_R], tone: "hi", crease: "lo", material: "steel" });
T.plane({ part: dialBevel, o: [0, 0, FACE_Z], u: [1, 0, 0], v: [0, 1, 0], svg: faceMarkup(), fade: [0.04, 0.3] });
void dialBody;
T.round({ name: "dial.hub", group: dial, layer: hand, F: DIAL_F, s0: FACE_Z, s1: HUB_TOP, r: HUB_R, tone: "mid", material: "chrome", details: { seams: [FACE_Z + 0.9] } });

const wheelFace = () => {
  const minor = [];
  const major = [];
  for (let i = 0; i < 100; i++) {
    const angle = (i / 100) * Math.PI * 2;
    (i % 10 ? minor : major).push(tick(angle, WHEEL_R - 3.2 - (i % 10 ? 1.2 : 2.4), WHEEL_R - 3.2));
  }
  const numerals = Array.from({ length: 10 }, (_, i) => {
    const angle = (i / 10) * Math.PI * 2;
    const r = WHEEL_R - 9.4;
    return `<text class="iso-face-text" data-tone="mid" font-size="4.2" text-anchor="middle" transform="translate(${(Math.sin(angle) * r).toFixed(3)} ${(-Math.cos(angle) * r).toFixed(3)}) rotate(${(i * 36).toFixed(1)}) translate(0 1.5)">${i}</text>`;
  }).join("");
  return `<path class="iso-line" data-tone="lo" d="${ringPath(WHEEL_R - 3.2, 180)}"/><path class="iso-line" data-tone="lo" d="${minor.join("")}"/><path class="iso-line" data-tone="mid" d="${major.join("")}"/>${numerals}`;
};
T.round({ name: "lock.pinion", group: dial, layer: lock, F: VERT(0, 0), s0: LOCK_Z0, s1: LOCK_Z1, r: PINION_R, tone: "mid", material: "brass", details: { ribs: { s0: LOCK_Z0 + 0.5, s1: LOCK_Z1 - 0.5, count: 48, seams: true, tone: "lo" } } });
const wheelDisc = T.round({ name: "lock.wheel", group: wheel, layer: lock, F: VERT(WHEEL_AT[0], WHEEL_AT[1]), s0: LOCK_Z0, s1: LOCK_Z1, r: WHEEL_R, tone: "hi", crease: "lo", material: "brass", details: { ribs: { s0: LOCK_Z0 + 0.5, s1: LOCK_Z1 - 0.5, count: 192, seams: true, tone: "lo" } } });
T.plane({ part: wheelDisc, o: [WHEEL_AT[0], WHEEL_AT[1], LOCK_Z1], u: [1, 0, 0], v: [0, 1, 0], svg: wheelFace() });
T.round({ name: "lock.wheel.hub", group: wheel, layer: lock, F: VERT(WHEEL_AT[0], WHEEL_AT[1]), s0: LOCK_Z1, s1: LOCK_Z1 + 0.5, r: 6, tone: "mid", material: "steel", details: { bolts: [{ s: LOCK_Z1 + 0.5, r: 3.8, count: 4, size: 0.5, phase: 0.4 }] } });

T.round({ name: "pawl.post", layer: face, F: VERT(PAWL_AT[0], PAWL_AT[1]), s0: PLATE_Z1, s1: PLATE_Z1 + 1, r: 2.4, tone: "mid", material: "gunmetal" });
const pawlRing = [];
for (let j = 0; j <= 10; j++) pawlRing.push([PAWL_AT[0] + 2.2 * Math.cos((j / 10) * Math.PI), PAWL_AT[1] + 2.2 * Math.sin((j / 10) * Math.PI)]);
pawlRing.push([PAWL_AT[0] - 0.6, DIAL_R + 0.4], [PAWL_AT[0], DIAL_R + 0.15], [PAWL_AT[0] + 0.6, DIAL_R + 0.4]);
T.prism({ name: "pawl.arm", group: pawl, layer: face, F: { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, polygon: pawlRing, s0: PLATE_Z1 + 1, s1: PLATE_Z1 + 2.6, tone: "hi", crease: "lo", bevel: 0.3, material: "chrome" });
T.round({ name: "pawl.pin", group: pawl, layer: face, F: VERT(PAWL_AT[0], PAWL_AT[1]), s0: PLATE_Z1 + 2.6, s1: PLATE_Z1 + 3.3, r: 1.1, tone: "mid", material: "steel", details: { bolts: [{ s: PLATE_Z1 + 3.3, r: 0, count: 1, size: 0.45 }] } });

const H = { group: dial, layer: hand };
const plan = (name, rect, z, h, extra = {}) => T.prism({ name, ...H, plan: rect, z, h, steps: extra.steps ?? 2, tone: "mid", ...extra });
const along = (name, F, s0, s1, r, extra = {}) => T.round({ name, ...H, F, s0, s1, r, tone: "mid", ...extra });
const frameOf = (o, a, u) => {
  const axis = G.unit3(a);
  const uu = u ? G.unit3(G.sub3(u, G.mul3(axis, G.dot3(u, axis)))) : G.frameAlong(o, axis).u;
  return { o, a: axis, u: uu, v: G.cross3(axis, uu) };
};

const sideScrews = [];
for (const [y, ny] of [[PALM.y, -1], [PALM.y + PALM.d, 1]]) for (const x of [-11, 3]) sideScrews.push({ at: [HUB_TOP + 5.2, x, y], normal: [0, ny, 0], size: 0.45, tone: "mid" });
for (const y of [-7, 7]) sideScrews.push({ at: [HUB_TOP + 5.2, PALM.x, y], normal: [-1, 0, 0], size: 0.45, tone: "mid" });
T.prism({ name: "hand.palm", ...H, plan: PALM, z: HUB_TOP, h: PALM_TOP - HUB_TOP, steps: 4, bevel: 0.9, tone: "hi", crease: "lo", material: "gold", details: { seams: [HUB_TOP + 2.6], dots: sideScrews, rings: sideScrews.map((d) => ({ at: d.at, normal: d.normal, r: 0.95, tone: "lo" })) } });
const coverScrews = [[-13.7, -11.4], [-13.7, 11.4], [9.3, -11.4], [9.3, 11.4]].map(([x, y]) => ({ at: [COVER_TOP, x, y], normal: [0, 0, 1], size: 0.48, tone: "mid" }));
T.prism({ name: "hand.cover", ...H, plan: COVER, z: PALM_TOP, h: COVER_TOP - PALM_TOP, steps: 3, bevel: 0.45, tone: "mid", material: "gold", details: { dots: coverScrews, rings: coverScrews.map((d) => ({ at: d.at, normal: [0, 0, 1], r: 1.1, tone: "lo" })) } });
const CONN = { x: -15.1, y: -10.4, w: 4.4, d: 20.8, r: 0.9 };
plan("hand.connector", CONN, COVER_TOP, 5.2, { bevel: 0.35, material: "gunmetal", details: { seams: [COVER_TOP + 1.2] } });
const CABLE_Z = COVER_TOP + 1.9;
const PORT_X = CONN.x + CONN.w;
const PORTS = { red: -8.6, green: -5.4, blue: 8.6 };
const CLAMP_X = -2.3;
const CLAMP_R = 1.45;
for (const [colour, y] of Object.entries(PORTS)) along(`hand.port.${colour}`, frameOf([PORT_X, y, CABLE_Z], [1, 0, 0], [0, 0, 1]), 0, 0.7, 1.15, { owner: `cable.${colour}`, material: "brass" });
const CLAMP_T = 1.1;
for (const colour of Object.keys(PORTS)) along(`hand.clamp.${colour}`, frameOf([CLAMP_X, PORTS[colour], CABLE_Z], [1, 0, 0], [0, 0, 1]), -CLAMP_T / 2, CLAMP_T / 2, CLAMP_R, { owner: `cable.${colour}`, material: "brass" });
plan("hand.clamp.left", { x: CLAMP_X - 1.3, y: -10.4, w: 2.6, d: 6.6, r: 0.6 }, COVER_TOP, CABLE_Z - CLAMP_R - COVER_TOP, { material: "gunmetal" });
plan("hand.clamp.right", { x: CLAMP_X - 1.3, y: 6.9, w: 2.6, d: 3.4, r: 0.6 }, COVER_TOP, CABLE_Z - CLAMP_R - COVER_TOP, { material: "gunmetal" });
const TERM = { x: 7.3, y: PORTS.green - 1.6, w: 2.6, d: 3.2, r: 0.5 };
plan("hand.terminal", TERM, COVER_TOP, CABLE_Z + 1.8 - COVER_TOP, { bevel: 0.3, material: "gunmetal" });

const PIST_Y = 0;
const BODY_R = 2.4;
const SADDLE_H = 0.9;
const PIST_Z = COVER_TOP + SADDLE_H + BODY_R;
const PIST_F = frameOf([0, PIST_Y, PIST_Z], [1, 0, 0], [0, 0, 1]);
const BODY = [-6.1, 2.9];
for (const [index, x] of [-5.1, 0.1].entries()) plan(`hand.saddle.${index}`, { x, y: PIST_Y - 1.9, w: 2.4, d: 3.8, r: 0.5 }, COVER_TOP, SADDLE_H, { material: "gunmetal" });
along("hand.piston.body", PIST_F, BODY[0], BODY[1], BODY_R, { tone: "hi", crease: "lo", material: "gunmetal", details: { seams: [BODY[0] + 1.6, BODY[1] - 1.6] } });
const GLAND = [BODY[1], BODY[1] + 1.4];
const glandRadius = (sv) => 2.3 + 0.55 * Math.sqrt(Math.max(0, 1 - ((sv - GLAND[0]) / (GLAND[1] - GLAND[0])) ** 2));
const glandSlope = (sv) => {
  const x = Math.min(0.999, (sv - GLAND[0]) / (GLAND[1] - GLAND[0]));
  return (-0.55 * x) / ((GLAND[1] - GLAND[0]) * Math.sqrt(1 - x * x));
};
T.lathe({ name: "hand.piston.gland", ...H, F: PIST_F, profile: [[GLAND[0], glandRadius(GLAND[0])], [GLAND[1], glandRadius(GLAND[1])]], radius: glandRadius, slope: glandSlope, tone: "mid", material: "brass", details: { bolts: [{ s: GLAND[1], r: 1.75, count: 6, phase: 0.3, size: 0.34, tone: "mid" }] } });
const ROD_END = 8.9;
along("hand.piston.rod", PIST_F, GLAND[1], ROD_END, 0.95, { tone: "hi", material: "chrome" });
plan("hand.clevis", { x: ROD_END, y: PIST_Y - 1.9, w: 2.4, d: 3.8, r: 0.5 }, COVER_TOP, PIST_Z + 2.4 - COVER_TOP, { bevel: 0.3, material: "steel", details: { dots: [-1, 1].map((side) => ({ at: [PIST_Z, ROD_END + 1.2, PIST_Y + side * 1.9], normal: [0, side, 0], size: 0.42 })) } });
const BALL_R = 2.2;
const BALL = [BODY[0] - BALL_R, PIST_Y, PIST_Z];
T.ball({ name: "hand.ball", ...H, c: BALL, r: BALL_R, tone: "hi", material: "chrome" });
along("hand.ball.post", VERT(BALL[0], BALL[1]), COVER_TOP, BALL[2] - BALL_R, 1.3, { material: "steel" });

const FINGERS = [
  { id: 1, y: -9.2, psi: -13, port: true, cable: "red" },
  { id: 2, y: 0, psi: 0, guide: true, flex: true },
  { id: 3, y: 9.2, psi: 13, port: true, cable: "blue" },
];
const D2R = Math.PI / 180;
const FZ = HUB_TOP + 4;
const RL = 2.35;
const LENGTHS = [7.4, 5.8, 3.6];
const GAP = 0.6;
const DISC_T = 1.2;
const PITCH = 8 * D2R;
const JUNCTION_H = 6;
export const TIPS = [];
export const LINKS = [];
export const LED = [];
const FINGER_PORTS = {};
for (const finger of FINGERS) {
  const psi = finger.psi * D2R;
  const d1 = [Math.cos(psi), Math.sin(psi), 0];
  const w = [-Math.sin(psi), Math.cos(psi), 0];
  const pitch = (a) => [Math.cos(a) * Math.cos(psi), Math.cos(a) * Math.sin(psi), -Math.sin(a)];
  const B = [PALM.x + PALM.w, finger.y, FZ];
  const R0 = 3.7;
  const e0 = R0 + Math.abs(Math.tan(psi)) * (RL + DISC_T);
  const C1 = G.add3(B, G.mul3(d1, e0));
  const E1 = G.add3(C1, G.mul3(d1, LENGTHS[0]));
  const d2 = pitch(PITCH);
  const C2 = G.add3(E1, G.mul3(G.unit3(G.add3(d1, d2)), 2 * RL + GAP));
  const J1 = G.mul3(G.add3(E1, C2), 0.5);
  const E2 = G.add3(C2, G.mul3(d2, LENGTHS[1]));
  const tipOf = (a3) => {
    const d3 = pitch(a3);
    const C3 = G.add3(E2, G.mul3(G.unit3(G.add3(d2, d3)), 2 * RL + GAP));
    return { d3, C3, E3: G.add3(C3, G.mul3(d3, LENGTHS[2])), J2: G.mul3(G.add3(E2, C3), 0.5) };
  };
  let lo = 0;
  let hi = 70 * D2R;
  for (let it = 0; it < 60; it++) {
    const mid = (lo + hi) / 2;
    if (tipOf(mid).E3[2] - RL > FACE_Z) lo = mid;
    else hi = mid;
  }
  const { C3, E3, J2 } = tipOf(hi);
  TIPS.push(E3);
  const tag = `hand.f${finger.id}`;
  const g1 = finger.flex ? T.group(`f${finger.id}.mcp`, { parent: dial, hinge: { point: C1, axis: w, range: [-22, 0] } }) : dial;
  const g2 = finger.flex ? T.group(`f${finger.id}.pip`, { parent: g1, hinge: { point: J1, axis: w, range: [-14, 0] } }) : dial;
  const g3 = finger.flex ? T.group(`f${finger.id}.dip`, { parent: g2, hinge: { point: J2, axis: w, range: [-9, 0] } }) : dial;
  if (finger.flex) LINKS.push({ tip: E3, dip: J2, groups: [g1.name, g2.name, g3.name], d3: G.unit3(G.sub3(E3, C3)), w });
  const link = (name, C, E, seams, group) => T.round({ name: `${tag}.${name}`, group, layer: hand, F: frameOf(C, G.sub3(E, C), [0, 0, 1]), s0: 0, s1: G.len3(G.sub3(E, C)), r: RL, ends: ["dome", "dome"], tone: "hi", crease: "lo", material: "gold", details: { seams } });
  link("p1", C1, E1, [1.2, LENGTHS[0] - 1.2], g1);
  link("p2", C2, E2, [1.0, LENGTHS[1] - 1.0], g2);
  const tipLink = link("p3", C3, E3, [0.9], g3);
  if (finger.flex) {
    const d3 = G.unit3(G.sub3(E3, C3));
    const led = G.add3(E3, G.mul3(d3, RL));
    LED.push({ at: led, normal: d3, part: tipLink.name, group: g3.name });
    T.billboard({ part: tipLink, at: led, normal: d3, fade: [0.02, 0.3], svg: `<g class="td-led"><circle r="1.15" class="td-led-halo"/><circle r="0.62" class="td-led-body"/><circle r="0.26" class="td-led-core"/></g>` });
  }
  const discs = (name, J, rDisc, group) => {
    for (const side of [-1, 1]) {
      const centre = G.add3(J, G.mul3(w, side * (RL + DISC_T / 2)));
      const F = frameOf(centre, G.mul3(w, side), [0, 0, 1]);
      T.round({ name: `${tag}.${name}${side > 0 ? "+" : "-"}`, group, layer: hand, F, s0: -DISC_T / 2, s1: DISC_T / 2, r: rDisc, tone: "mid", material: "chrome", details: { dots: [{ at: [DISC_T / 2, 0, 0], normal: G.mul3(w, side), size: 0.42 }], rings: [{ at: [DISC_T / 2, 0, 0], normal: G.mul3(w, side), r: 0.85, tone: "lo" }] } });
    }
  };
  discs("k0", C1, R0, dial);
  discs("k1", J1, 3.5, g1);
  discs("k2", J2, 3.3, g2);
  if (finger.port) {
    const M = G.mul3(G.add3(C1, E1), 0.5);
    void M;
    const ring = [[-2.1, -1.7], [2.1, -1.7], [2.1, 1.7], [-2.1, 1.7]].map(([a, b]) => [M[0] + d1[0] * a + w[0] * b, M[1] + d1[1] * a + w[1] * b]);
    const z0 = M[2] + RL;
    T.prism({ name: `${tag}.junction`, ...H, F: { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, polygon: ring, s0: z0, s1: z0 + JUNCTION_H, tone: "mid", bevel: 0.3, material: "gunmetal", details: { seams: [z0 + 1] } });
    const back = [M[0] - d1[0] * 2.1, M[1] - d1[1] * 2.1, z0 + JUNCTION_H * 0.62];
    along(`${tag}.port`, frameOf(back, G.mul3(d1, -1), [0, 0, 1]), 0, 0.6, 0.95, { owner: `cable.${finger.cable}`, material: "brass" });
    FINGER_PORTS[finger.id] = { face: back, d1 };
  }
  if (finger.guide) {
    const n = G.unit3(G.cross3(d2, w));
    const up = n[2] > 0 ? n : G.mul3(n, -1);
    T.prism({ name: `${tag}.guide`, group: g2, layer: hand, F: { o: C2, a: d2, u: w, v: up }, polygon: [[-1.5, RL], [1.5, RL], [1.15, RL + 0.9], [-1.15, RL + 0.9]], s0: 1.1, s1: LENGTHS[1] - 1.1, tone: "mid", bevel: 0.25, material: "gunmetal" });
  }
}

const CABLE_R = 0.7;
const BEND = 8 * CABLE_R;
const portStart = (colour) => [PORT_X, PORTS[colour], CABLE_Z];
const cable = (colour, waypoints, ends = [0.7, 0]) => {
  const points = G.fillet(waypoints, BEND, 12);
  const at = (x) => {
    let walked = 0;
    for (let i = 1; i < points.length; i++) {
      const a = points[i - 1];
      const b = points[i];
      const length = G.len3(G.sub3(b, a));
      if ((a[0] - x) * (b[0] - x) <= 0 && Math.abs(b[0] - a[0]) > 1e-9) return walked + (length * (x - a[0])) / (b[0] - a[0]);
      walked += length;
    }
    return null;
  };
  const hit = at(CLAMP_X);
  const total = G.pathLength(points);
  const gaps = [[0, ends[0]], ...(hit === null ? [] : [[hit - CLAMP_T / 2, hit + CLAMP_T / 2]]), ...(ends[1] ? [[total - ends[1], total]] : [])];
  T.tube({ name: `cable.${colour}`, ...H, route: points, r: CABLE_R, chunk: 6, gaps, hue: colour, tone: "hi", material: "rubber" });
};
const approach = (port, length, rise) => G.add3(G.add3(port.face, G.mul3(port.d1, -length)), [0, 0, rise]);
cable("red", [portStart("red"), [-0.4, PORTS.red, CABLE_Z], approach(FINGER_PORTS[1], 7.6, 0.3), FINGER_PORTS[1].face], [0.7, 0.6]);
cable("green", [portStart("green"), [TERM.x, PORTS.green, CABLE_Z]]);
cable("blue", [portStart("blue"), [-0.4, PORTS.blue, CABLE_Z], approach(FINGER_PORTS[3], 7.6, 0.3), FINGER_PORTS[3].face], [0.7, 0.6]);
const HOSE_R = 0.9;
const HOSE_Y = 4.6;
const HOSE_Z = COVER_TOP + 3.3;
const VALVE = { x: 3.4, y: HOSE_Y - 1.8, w: 3.4, d: 3.6, r: 0.6 };
plan("hand.valve", VALVE, COVER_TOP, HOSE_Z + 1.9 - COVER_TOP, { bevel: 0.3, material: "gunmetal", details: { seams: [COVER_TOP + 1.2], dots: [[VALVE.x + 0.8, VALVE.y + 0.8], [VALVE.x + VALVE.w - 0.8, VALVE.y + VALVE.d - 0.8]].map(([x, y]) => ({ at: [HOSE_Z + 1.9, x, y], normal: [0, 0, 1], size: 0.4 })) } });
const FERRULE = 1.3;
const hoseFrom = [PORT_X, HOSE_Y, HOSE_Z];
const hoseTo = [VALVE.x, HOSE_Y, HOSE_Z];
along("hand.hose.ferrule.0", frameOf(hoseFrom, [1, 0, 0], [0, 0, 1]), 0, FERRULE, 1.25, { owner: "hand.hose", material: "brass", details: { seams: [0.45] } });
along("hand.hose.ferrule.1", frameOf(hoseTo, [-1, 0, 0], [0, 0, 1]), 0, FERRULE, 1.25, { owner: "hand.hose", material: "brass", details: { seams: [0.45] } });
const hoseLength = G.len3(G.sub3(hoseTo, hoseFrom));
T.tube({ name: "hand.hose", ...H, route: [hoseFrom, hoseTo], r: HOSE_R, chunk: 6, gaps: [[0, FERRULE], [hoseLength - FERRULE, hoseLength]], rings: { pitch: 1.5, twist: 1.6, cross: true, fade: [0.1, 0.55], tone: "lo" }, tone: "hi", material: "steel" });

const KNUCKLE_R = 3.2;
const KNUCKLE_D = 2.6;
const KNUCKLE = [-4, PALM.y - KNUCKLE_D, FZ];
T.ball({ name: "hand.thumb.knuckle", ...H, c: KNUCKLE, r: KNUCKLE_R, flats: [{ n: [0, 1, 0], d: KNUCKLE_D }, { n: [0, -1, 0], d: KNUCKLE_D }], tone: "hi", material: "chrome" });
const THUMB_F = frameOf([KNUCKLE[0], KNUCKLE[1] - KNUCKLE_D, KNUCKLE[2]], [0, -1, 0], [0, 0, 1]);
T.round({ name: "hand.thumb.cone", ...H, F: THUMB_F, s0: 0, s1: 3, r: [1.8, 1.25], tone: "mid", material: "steel" });
const THUMB_R = 1.9;
T.round({ name: "hand.thumb.link", ...H, F: frameOf(G.pointOf(THUMB_F, 3 + THUMB_R, 0, 0), [0, -1, 0], [0, 0, 1]), s0: 0, s1: 6.5, r: THUMB_R, ends: ["dome", "dome"], tone: "hi", crease: "lo", material: "gold", details: { seams: [1.2, 5.3] } });

const POD = [-7, 11.4];
const POD_Z = COVER_TOP + 0.3;
T.round({ name: "hand.pod.socket", ...H, F: VERT(POD[0], POD[1]), s0: COVER_TOP, s1: POD_Z, r: 1.7, tone: "mid", material: "gunmetal" });
const podGroup = T.group("pod", { parent: dial, free: { origin: [POD[0], POD[1], POD_Z] } });
const hex = Array.from({ length: 6 }, (_, j) => [1.35 * Math.cos(((2 * j + 1) * Math.PI) / 6), 1.35 * Math.sin(((2 * j + 1) * Math.PI) / 6)]);
T.prism({ name: "hand.pod.nut", group: podGroup, layer: hand, F: { o: [POD[0], POD[1], 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, polygon: hex, s0: POD_Z, s1: POD_Z + 1.4, tone: "hi", crease: "lo", bevel: 0.2, material: "brass" });
T.round({ name: "hand.pod.cap", group: podGroup, layer: hand, F: VERT(POD[0], POD[1]), s0: POD_Z + 1.4, s1: POD_Z + 2.0, r: 0.9, tone: "mid", material: "chrome", details: { bolts: [{ s: POD_Z + 2.0, r: 0, count: 1, size: 0.4 }] } });
export const POD_AT = [POD[0], POD[1], POD_Z];

base();
backPosts();
midStatic();
const verify = argv.includes("--verify");
const out = T.build({ statics: R.items, poses, verify });
A.settle(R, P);
const layerOf = (name) => R.items.filter((item) => item.layer === name).sort((a, b) => a.key - b.key).map((item) => item.svg);
const BACK = [`<path class="iso-halo" d="${k.haloOf(BASE, -BASE_H - FOOT_H, BASE_H, P)}"/>`, ...layerOf("back")];
const MID = layerOf("mid");
const FRONT = layerOf("front");
if (argv.includes("--audit")) {
  A.auditOrExit(R, P);
  TA.orbitOrExit(T, R, P, {
    sweep: { at: (x) => follow({ dial: x }), from: 0, to: 1440, frame: 0.3 },
    clips: [
      { name: "tap", at: (t) => follow({ dial: 40 + 12 * t, lift: tapAt(t) }), from: 0, to: 4, frame: 1 / 60 },
      { name: "break", at: (t) => follow({ dial: 100 + 12 * t, lift: 0.3, pod: breakAt(t) }), from: 0, to: BREAK_END, frame: 1 / 60 },
    ],
    crowd: [["cable.red", "hand.f1.p2"], ["cable.red", "hand.f2.p1"], ["cable.red", "hand.f2.p2"], ["cable.red", "hand.thumb.link"], ["cable.blue", "hand.f2.p1"], ["cable.blue", "hand.f3.p2"]], quick: argv.includes("--quick"), only: argv.find((a) => a.startsWith("--only="))?.slice(7).split(",") ?? null, log: argv.includes("--verbose") ? (text) => console.log(`  … ${text}`) : null });
}

const LABEL = "A combination dial on a raised door plate. A small robot hand lies on the dial's hub and turns with it: a palm with a cover plate, a connector block, a piston on two saddles with a ball joint, three jointed fingers whose tips rest on the dial face, and a thumb on a ball knuckle.";
const backSvg = k.figureSvg({ width: WIDTH, height: HEIGHT, label: LABEL, body: BACK });
const staticSvg = (list) => `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg iso-layer" viewBox="0 0 ${WIDTH} ${HEIGHT}" aria-hidden="true">${list.join("")}</svg>`;
const frontSvg = staticSvg(FRONT);
const canvas = (name) => `<canvas class="iso-layer td-gl" data-canvas="${name}" aria-hidden="true"></canvas>`;
const stage = `<div class="iso-stage iso-turn td-stage" tabindex="0" role="slider" aria-label="Combination dial: drag around the dial or use the arrow keys to turn it, press B to break the pod off" aria-valuemin="0" aria-valuemax="99" aria-valuenow="0" aria-valuetext="dial at 0">${backSvg}${out.layerSvg("lock", { width: WIDTH, height: HEIGHT })}${staticSvg(MID)}${out.layerSvg("dial", { width: WIDTH, height: HEIGHT })}${canvas("back")}${out.layerSvg("hand", { width: WIDTH, height: HEIGHT })}${canvas("front")}${frontSvg}</div>`;

const DATA = { turn: out.data, index: INDEX_AT, pod: POD_AT, breakEnd: BREAK_END, links: LINKS, gl: { W: WIDTH, H: HEIGHT, camera: glCamera(P), led: LED[0], faceZ: FACE_Z, faceR: FACE_R, hubR: HUB_R, socket: [POD_AT[0], POD_AT[1], POD_AT[2]] } };
const FOLLOW = `const follow = ${follow.toString()};\nconst tapAt = ${tapAt.toString()};\nconst breakAt = ${breakAt.toString()};`;
const CSS = out.css + "\n" + readFileSync(join(HERE, "dial.css"), "utf8");
const LIVE = readFileSync(join(HERE, "live.js"), "utf8").replace("__DATA__", JSON.stringify(DATA)).replace("const follow = null;", FOLLOW);
const body = k.plateHtml({
  fig: "Fig 3D",
  title: "Turning dial",
  hint: "Drag the dial · arrow keys · B breaks the pod",
  readout: "dial 0 · 0.0°",
  keys: [
    { mark: "raised", label: "The dial and the hand on it: one rigid group" },
    { mark: "lit", label: "The index" },
  ],
  caption: "The optional 3D mode: every part on the dial is re-projected and re-shaded each frame by the kit's own rules, and painted in an order solved from separating planes.",
  body: `<style>${CSS}</style>${stage}`,
});
const page = k.pageHtml({ title: "Turning dial", theme, body, script: turnScript() + glScript() + (verify ? `window.__isoVerify = ${JSON.stringify(out.verify)};\n` : "") + LIVE, width: 680 });
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  const name = `turning-dial${verify ? "-verify" : ""}${theme === "light" ? "-light" : ""}.html`;
  writeFileSync(join(HERE, name), page);
  console.log(`wrote ${name} · ${(page.length / 1024).toFixed(0)} KB · ${out.report.parts} parts · ${out.report.layers.map((l) => `${l.name}: ${l.planar} planes, ${l.dynamic} dynamic`).join(" · ")} · build ${out.report.seconds.toFixed(1)} s`);
}

export { out };
```

## examples/turning-dial/dial.css

```

```

## examples/turning-dial/live.js

```js
const DATA = __DATA__;
const follow = null;
const stage = document.querySelector(".td-stage");
const readout = document.querySelector("[data-readout]");
const still = matchMedia("(prefers-reduced-motion: reduce)");
const INDEX = (DATA.index * 180) / Math.PI;
let angle = 0;
let target = 0;
let frame = 0;
let last = 0;
let visible = false;
let touring = !still.matches;
let idle = 0;
let drawn = null;
let clock = 0;
let lift = 0;
let broken = 0;
let breaking = null;
let podHook;
const SPEED = 18;
const readingOf = (turn) => ((((INDEX + 90 - turn) / 3.6) % 100) + 100) % 100;
const describe = () => {
  const reading = Math.round(readingOf(target)) % 100;
  stage.setAttribute("aria-valuenow", String(reading));
  stage.setAttribute("aria-valuetext", `dial at ${reading}${broken ? ", the pod has broken off" : ""}`);
};
const hold = () => {
  touring = false;
  clearTimeout(idle);
  idle = setTimeout(() => {
    touring = !still.matches;
    run();
  }, 3600);
};
let own = false;
const show = (value) => {
  const text = `dial ${Math.round(readingOf(value)) % 100} · ${(((value % 360) + 360) % 360).toFixed(1)}°${broken ? " · pod off" : ""}`;
  if (readout.textContent !== text) readout.textContent = text;
};
const podAt = () => {
  if (!breaking && podHook !== undefined) return podHook;
  if (!breaking) return broken ? breakAt(DATA.breakEnd) : null;
  const t = Math.min(DATA.breakEnd, breaking.time);
  return breakAt(breaking.reverse ? DATA.breakEnd - t : t);
};
let paint = () => {};
const controller = TURN.mount(stage, DATA.turn, {
  follow,
  onHold: () => {
    touring = false;
    clearTimeout(idle);
  },
  onFrame: (turned) => {
    const value = turned.values.dial;
    if (!own) {
      angle = target = drawn = value;
      lift = turned.values.lift ?? lift;
      if ("pod" in turned.values) podHook = turned.values.pod;
      describe();
    }
    show(value);
    paint();
  },
});
const GLD = DATA.gl;
const LIGHT = (stage.closest("[data-theme]")?.getAttribute("data-theme") ?? "dark") !== "dark" ? 1 : 0;
const INK = `
uniform float uLight;
vec4 over(vec4 top,vec4 under){return top+under*(1.-top.a);}
vec4 inkFor(vec3 seen,float cover){vec3 absorbed=1.-seen;float a=max(max(absorbed.r,max(absorbed.g,absorbed.b)),cover);return vec4(vec3(a)-absorbed,a);}
vec4 finish(vec4 ink,vec2 frag){ink.rgb=min(ink.rgb,vec3(ink.a));float n=(hash12(frag)-.5)/255.*smoothstep(0.,1.5/255.,ink.a);return clamp(ink+vec4(n),0.,1.);}
`;
const BACK = `${glslPose("Dial")}${INK}
uniform vec3 uLed;
uniform vec3 uLedDir;
uniform float uOn;
const float FACE_Z=${GLD.faceZ.toFixed(3)};
const float FACE_R=${GLD.faceR.toFixed(3)};
const float HUB_R=${GLD.hubR.toFixed(3)};
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  vec3 p=onFloor(vb,FACE_Z);
  vec3 q=toDial(p);
  float r=length(q.xy);
  float face=(1.-smoothstep(FACE_R-3.,FACE_R-1.,r))*smoothstep(HUB_R+.2,HUB_R+1.6,r);
  vec3 v=p-uLed;
  float d2=dot(v,v);
  float d=sqrt(d2);
  float lens=.3+.7*pow(max(0.,dot(v/d,uLedDir)),1.5);
  float lambert=max(0.,(uLed.z-FACE_Z+.6)/d);
  float grain=.84+.16*noise2(vec2(atan(q.y,q.x)*180.,r*.6));
  float e=420.*lens*lambert/(d2+10.)*grain*face;
  float lit=1.-exp(-e);
  vec3 tint=vec3(.40,.84,.60);
  vec4 light=inkFor(mix(vec3(1.),tint,.7*lit),0.);
  vec3 glow=vec3(.25,1.,.58)*lit*.55;
  vec4 dark=vec4(glow,max(glow.r,max(glow.g,glow.b)));
  gl_FragColor=finish(mix(dark,light,uLight)*uOn,gl_FragCoord.xy);
}`;
const FRONT = `${GLSL_TURN}${INK}
uniform vec2 uLedVb;
uniform float uLedR;
uniform float uLedFace;
uniform float uOn;
uniform vec4 uSpark[24];
uniform float uSparkHeat[24];
vec4 sparkInk(float heat,float along,float young){
  vec3 tail=mix(vec3(.78,.2,.04),vec3(1.,.55,.08),smoothstep(.35,.8,heat));
  vec3 head=mix(tail,vec3(1.,.74,.2),smoothstep(.55,.9,heat)*smoothstep(.5,1.,along));
  vec3 seen=mix(head,vec3(1.,.95,.82),young*smoothstep(.8,1.,along));
  float a=.88*smoothstep(0.,.3,along)*smoothstep(.12,.45,heat);
  vec4 light=vec4(seen*a,a);
  vec3 hot=mix(vec3(1.,.35,.05),vec3(1.,.9,.6),young*along)*(.4+.6*heat);
  vec4 dark=vec4(hot*a,a);
  return mix(dark,light,uLight);
}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  float px=uView.x/uCanvas.x;
  float soft=.7*px;
  float d=length(vb-uLedVb);
  float body=1.-smoothstep(uLedR-.6*px,uLedR+.6*px,d);
  float core=1.-smoothstep(uLedR*.32,uLedR*.55+.5*px,d);
  float rim=smoothstep(uLedR*.55,uLedR,d);
  vec3 seen=mix(mix(vec3(.18,.70,.43),vec3(.11,.52,.31),rim),vec3(.90,1.,.94),core);
  vec4 lightLed=inkFor(seen,.92)*body;
  vec3 hot=mix(vec3(.2,1.,.55),vec3(.92,1.,.95),core)*body;
  vec4 darkLed=vec4(hot,body);
  vec4 led=mix(darkLed,lightLed,uLight)*smoothstep(.02,.3,uLedFace)*uOn;
  vec4 sparks=vec4(0.);
  float w=max(.35+.6*px,1.25*px);
  for(int i=0;i<24;i++){
    float heat=uSparkHeat[i];
    if(heat<=0.)continue;
    vec4 s=uSpark[i];
    vec2 a=s.zw;
    vec2 b=s.xy;
    vec2 ab=b-a;
    float l2=max(dot(ab,ab),1e-6);
    float t=clamp(dot(vb-a,ab)/l2,0.,1.);
    float dist=length(vb-a-ab*t);
    float cover=1.-smoothstep(w*.5-.5*px,w*.5+.5*px,dist);
    if(cover<=0.)continue;
    sparks=over(sparkInk(heat,t,step(.65,heat))*cover,sparks);
  }
  vec4 ink=over(sparks,led)*(1.-coverOf(vb,soft));
  gl_FragColor=finish(ink,gl_FragCoord.xy);
}`;
const canvasOf = (name) => stage.querySelector(`[data-canvas="${name}"]`);
const view = [GLD.W, GLD.H];
const backLayer = glLayer({ canvas: canvasOf("back"), fragment: BACK, view, camera: GLD.camera, uniforms: ["uLed", "uLedDir", "uOn", "uDialX", "uDialY", "uDialZ", "uDialT"], fixed: { uLight: LIGHT } });
const frontLayer = backLayer ? glLayer({ canvas: canvasOf("front"), fragment: FRONT, view, camera: GLD.camera, uniforms: ["uLedVb", "uLedR", "uLedFace", "uOn", "uSpark", "uSparkHeat", "uCoverEdge", "uCoverCount"], fixed: { uLight: LIGHT } }) : null;
const roomy = frontLayer ? frontLayer.gl.getParameter(frontLayer.gl.MAX_FRAGMENT_UNIFORM_VECTORS) >= 128 : false;
if (backLayer && frontLayer && roomy) stage.setAttribute("data-gl", "");
const SPARKS = 24;
const sparkData = new Float32Array(SPARKS * 4);
const sparkHeat = new Float32Array(SPARKS);
const sparks = [];
let first = { x: 0, v: 0 };
let glClock = 0;
let seed = 7;
const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const ignite = () => {
  const at = controller.anchor("dial", GLD.socket).world;
  const spin = ((angle - (drawn ?? angle)) * Math.PI) / 180;
  void spin;
  for (let i = 0; i < SPARKS; i++) {
    const a = random() * Math.PI * 2;
    const tilt = 0.35 + random() * 0.9;
    const speed = 16 + random() * 22;
    sparks[i] = { p: at.slice(), v: [Math.cos(a) * Math.sin(tilt) * speed, Math.sin(a) * Math.sin(tilt) * speed, Math.cos(tilt) * speed], age: -random() * 0.08, life: 0.3 + random() * 0.45 };
  }
};
const stepSparks = (dt) => {
  let live = false;
  for (let i = 0; i < SPARKS; i++) {
    const s = sparks[i];
    sparkHeat[i] = 0;
    if (!s) continue;
    s.age += dt;
    if (s.age < 0) {
      live = true;
      continue;
    }
    if (s.age > s.life) {
      sparks[i] = null;
      continue;
    }
    s.v[2] -= 98 * dt;
    for (let c = 0; c < 3; c++) s.p[c] += s.v[c] * dt;
    if (s.p[2] < GLD.faceZ) {
      sparks[i] = null;
      continue;
    }
    live = true;
  }
  return live;
};
const writeSparks = () => {
  const w = controller.cams().world;
  const project = (p) => [w.ox + w.m[0] * p[0] + w.m[1] * p[1] + w.m[2] * p[2], w.oy + w.m[3] * p[0] + w.m[4] * p[1] + w.m[5] * p[2]];
  for (let i = 0; i < SPARKS; i++) {
    const s = sparks[i];
    if (!s || s.age < 0) {
      sparkHeat[i] = 0;
      continue;
    }
    const head = project(s.p);
    const tail = project([s.p[0] - s.v[0] * 0.05, s.p[1] - s.v[1] * 0.05, s.p[2] - s.v[2] * 0.05]);
    sparkData.set([head[0], head[1], tail[0], tail[1]], 4 * i);
    sparkHeat[i] = 1 - s.age / s.life;
  }
};
paint = () => {
  if (!stage.hasAttribute("data-gl")) return;
  const led = controller.anchor(GLD.led.group, GLD.led.at, GLD.led.normal);
  const world = controller.cams().world;
  const facing = led.direction[0] * world.V[0] + led.direction[1] * world.V[1] + led.direction[2] * world.V[2];
  const on = first.x;
  backLayer.draw({ uLed: led.world, uLedDir: led.direction, uOn: on, ...TURN.poseUniforms(controller, "dial", "uDial") }, glClock);
  writeSparks();
  const live = sparks.some(Boolean);
  const cover = TURN.cover(controller, live ? [[GLD.led.part], ["hand.pod.socket"]] : [GLD.led.part]);
  frontLayer.draw({ uLedVb: led.screen, uLedR: 0.52 * world.k, uLedFace: facing, uOn: on, uSpark: sparkData, uSparkHeat: sparkHeat, uCoverEdge: cover.uCoverEdge, uCoverCount: cover.uCoverCount }, glClock);
  const u = on;
  const alpha = 0.92;
  stage.style.setProperty("--td-fallback", String(Math.max(0, (1 - u) / (1 - u * alpha + 1e-6))));
};
const draw = () => {
  own = true;
  controller.set({ dial: angle, lift, pod: podAt() });
  own = false;
  drawn = angle;
};
function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  const calm = still.matches;
  if (touring) {
    target += SPEED * dt;
    clock += dt;
  }
  angle = calm ? target : angle + (target - angle) * (1 - Math.exp(-dt / 0.12));
  if (Math.abs(target - angle) < 1e-3) angle = target;
  const nextLift = calm ? 0 : touring ? lift + (tapAt(clock) - lift) * (1 - Math.exp(-dt / 0.15)) : lift;
  let moved = Math.abs(nextLift - lift) > 1e-5;
  lift = nextLift;
  glClock += dt;
  const before = first.x;
  const lighting = stage.hasAttribute("data-gl") && settle(first, 1, dt, 0.35, calm);
  if (first.x !== before) moved = true;
  const sparking = stage.hasAttribute("data-gl") && stepSparks(calm ? 1 : dt);
  if (lighting || sparking) moved = true;
  if (breaking) {
    if (!breaking.reverse && breaking.time === 0 && !calm) ignite();
    breaking.time += calm ? DATA.breakEnd : dt;
    moved = true;
    if (breaking.time >= DATA.breakEnd) {
      broken = breaking.reverse ? 0 : 1;
      breaking = null;
      describe();
    }
  }
  if (moved || drawn === null || Math.abs(angle - drawn) > 1e-4) draw();
  const settled = !touring && angle === target && !breaking && !lighting && !sparking;
  frame = visible && !settled ? requestAnimationFrame(tick) : 0;
}
const run = () => {
  if (!frame && visible) {
    last = 0;
    frame = requestAnimationFrame(tick);
  }
};
new IntersectionObserver(
  (entries) => {
    visible = entries[entries.length - 1].isIntersecting;
    if (visible) run();
    else if (frame) {
      cancelAnimationFrame(frame);
      frame = 0;
    }
  },
  { rootMargin: "120px 0px" },
).observe(stage);
let drag = null;
stage.addEventListener("pointerdown", (event) => {
  hold();
  drag = { from: controller.angleAt(event.clientX, event.clientY, 17.2, "dial"), base: target, id: event.pointerId, captured: false };
});
stage.addEventListener("pointermove", (event) => {
  if (!drag || event.pointerId !== drag.id) return;
  hold();
  const now = controller.angleAt(event.clientX, event.clientY, 17.2, "dial");
  const delta = now - drag.from;
  if (!drag.captured && Math.abs(delta) > 1.5) {
    drag.captured = true;
    stage.setPointerCapture(event.pointerId);
    stage.setAttribute("data-drag", "");
  }
  if (!drag.captured) return;
  target = drag.base + delta;
  describe();
  run();
});
const release = (event) => {
  if (!drag || event.pointerId !== drag.id) return;
  drag = null;
  stage.removeAttribute("data-drag");
  try {
    stage.releasePointerCapture(event.pointerId);
  } catch {}
};
stage.addEventListener("pointerup", release);
stage.addEventListener("pointercancel", release);
stage.addEventListener("keydown", (event) => {
  if (event.key === "b" || event.key === "B") {
    event.preventDefault();
    podHook = undefined;
    if (breaking) breaking = { reverse: !breaking.reverse, time: DATA.breakEnd - breaking.time };
    else breaking = { reverse: Boolean(broken), time: 0 };
    run();
    return;
  }
  const step = event.shiftKey ? 36 : 3.6;
  let next = null;
  if (event.key === "ArrowRight" || event.key === "ArrowUp") next = Math.round(target / 3.6) * 3.6 - step;
  if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = Math.round(target / 3.6) * 3.6 + step;
  if (event.key === "Home") next = INDEX + 90 - 360 * Math.round((INDEX + 90 - target) / 360);
  if (next === null) return;
  event.preventDefault();
  hold();
  target = next;
  describe();
  run();
});
if (still.matches) touring = false;
draw();
describe();
```

## examples/turning-dial/stress.mjs

```js
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as k from "../../kit/iso-kit.mjs";
import * as G from "../../kit/lathe.mjs";
import * as A from "../../kit/audit.mjs";
import { tubePieces, tubeSvg } from "../../kit/tube.mjs";
import { turning } from "../../kit/turn-build.mjs";
import * as TA from "../../kit/turn-audit.mjs";
import { turnScript } from "../../scripts/inline-kit.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const theme = argv.includes("--light") ? "light" : "dark";
const WIDTH = 760;
const HEIGHT = 560;
const BENCH = { x: -120, y: -105, w: 240, d: 210, r: 6 };
const BENCH_TOP = -20;

export const P = k.fitProjection([...k.boxCorners(BENCH, BENCH_TOP - 6, BENCH_TOP), [0, 0, 30]], WIDTH, HEIGHT, { pad: 20, azimuth: 40, elevation: 31 });
export const R = A.recorder(P);
const put = (item) => R.put({ ...item, shapes: item.shapes.map((shape, index) => R.solid(Object.assign(shape, { name: shape.name ?? (index ? `${item.name}#${index + 1}` : item.name) }))) });
const S = (paths, style) => k.solidSvg(paths, style);
const L = (d, style) => k.lineSvg(d, style);
const VERT = (x, y) => ({ o: [x, y, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] });
const mat = (name, svg) => `<g data-mat="${name}">${svg}</g>`;

function workshop() {
  put({ name: "stand.bench", layer: "back", at: [0, 0, BENCH_TOP - 6], bias: 8, svg: S(k.slabOf(BENCH, BENCH_TOP - 6, 6, P, 8, 1.2), { tone: "lo", inner: L(k.sideSeam(BENCH, BENCH_TOP - 2.4, P, 8), { tone: "faint" }) }), shapes: [A.slab(BENCH, BENCH_TOP - 6, 6, { ground: true })] });
  put({ name: "stand.base.flange", layer: "back", at: [0, 0, BENCH_TOP], svg: mat("gunmetal", S(k.cylinder(0, 0, 48, BENCH_TOP, 2, P, 96), { tone: "mid" })), shapes: [A.cylinder(0, 0, 48, BENCH_TOP, 2)] });
  put({ name: "stand.base", layer: "back", at: [0, 0, BENCH_TOP + 2], svg: mat("gunmetal", S(k.cylinder(0, 0, 44, BENCH_TOP + 2, 14, P, 96), { tone: "mid", inner: L(G.arcOf(VERT(0, 0), BENCH_TOP + 9, 44, P, { least: 0 }), { tone: "faint" }) })), shapes: [A.cylinder(0, 0, 44, BENCH_TOP + 2, 14)] });
  const unit = { x: -104, y: -62, w: 34, d: 26, r: 2 };
  const vents = Array.from({ length: 9 }, (_, i) => k.pathOf([k.iso([unit.x + 4 + i * 3.2, unit.y, BENCH_TOP + 5], P), k.iso([unit.x + 4 + i * 3.2, unit.y, BENCH_TOP + 15], P)])).join("");
  put({ name: "stand.power", layer: "back", at: [unit.x + 17, unit.y + 13, BENCH_TOP], svg: mat("gunmetal", S(k.slabOf(unit, BENCH_TOP, 20, P, 4, 0.8), { tone: "hi", inner: L(vents, { tone: "faint" }) })), shapes: [A.slab(unit, BENCH_TOP, 20)] });
  for (const [i, x] of [-98, -90, -82].entries()) put({ name: `stand.power.knob.${i}`, layer: "back", at: [x, unit.y + unit.d + 0.6, BENCH_TOP + 22], svg: mat("brass", S(k.cylinder(x, unit.y + 8 + i * 5, 1.6, BENCH_TOP + 20, 1.6, P, 24), { tone: "hi" })), shapes: [A.cylinder(x, unit.y + 8 + i * 5, 1.6, BENCH_TOP + 20, 1.6)] });
  const rack = { x: -60, y: -100, w: 70, d: 10, r: 1 };
  put({ name: "stand.rack", layer: "back", at: [rack.x + 35, rack.y + 5, BENCH_TOP], svg: mat("steel", S(k.slabOf(rack, BENCH_TOP, 26, P, 2, 0.6), { tone: "mid" })), shapes: [A.slab(rack, BENCH_TOP, 26)] });
  for (let i = 0; i < 7; i++) {
    const x = rack.x + 6 + i * 9.4;
    const ring = [[x - 1, rack.y + rack.d], [x + 1, rack.y + rack.d], [x + 1, rack.y + rack.d + 1.4], [x - 1, rack.y + rack.d + 1.4]];
    put({ name: `stand.rack.tool.${i}`, layer: "back", at: [x, rack.y + rack.d, BENCH_TOP + 8], svg: mat(i % 2 ? "chrome" : "steel", S(k.extrude(ring, BENCH_TOP + 6 + (i % 3) * 2, 16, P), { tone: "hi" })), shapes: [A.extruded(ring, BENCH_TOP + 6 + (i % 3) * 2, 16)] });
  }
  const reel = [-88, 40];
  put({ name: "stand.reel", layer: "back", at: [reel[0], reel[1], BENCH_TOP], svg: mat("copper", S(k.cylinder(reel[0], reel[1], 9, BENCH_TOP, 4, P, 64), { tone: "hi" })), shapes: [A.cylinder(reel[0], reel[1], 9, BENCH_TOP, 4)] });
  const tray = { x: 62, y: 46, w: 36, d: 22, r: 2 };
  put({ name: "stand.tray", layer: "back", at: [tray.x + 18, tray.y + 11, BENCH_TOP], svg: mat("steel", S(k.slabOf(tray, BENCH_TOP, 3, P, 4, 0.5), { tone: "mid" })), shapes: [A.slab(tray, BENCH_TOP, 3)] });
  const wrench = [[70, -70], [96, -64], [97, -61], [71, -67]];
  put({ name: "stand.wrench", layer: "back", at: [84, -66, BENCH_TOP], svg: mat("chrome", S(k.extrude(wrench, BENCH_TOP, 0.9, P), { tone: "hi" })), shapes: [A.extruded(wrench, BENCH_TOP, 0.9)] });
  const cables = [
    { name: "stand.feed", r: 1.1, route: G.fillet([[unit.x + unit.w, unit.y + 8, BENCH_TOP + 1.1], [-60, unit.y + 8, BENCH_TOP + 1.1], [-46, -30, BENCH_TOP + 1.1], [-44 * Math.cos(0.6), -44 * Math.sin(0.6), BENCH_TOP + 1.1]], 9, 12), hue: "red" },
    { name: "stand.feed.2", r: 0.8, route: G.fillet([[unit.x + unit.w, unit.y + 16, BENCH_TOP + 0.8], [-62, unit.y + 16, BENCH_TOP + 0.8], [-50, -24, BENCH_TOP + 0.8], [-44 * Math.cos(0.5), -44 * Math.sin(0.5), BENCH_TOP + 0.8]], 7, 12), hue: "blue" },
    { name: "stand.loose", r: 0.7, route: G.fillet([[reel[0] + 9, reel[1], BENCH_TOP + 0.7], [-66, 52, BENCH_TOP + 0.7], [-60, 76, BENCH_TOP + 0.7], [-30, 84, BENCH_TOP + 0.7]], 6, 12), hue: "green" },
  ];
  for (const c of cables) {
    R.route(c.name, c.route, c.r, { owner: c.name, limp: true, ends: [true, c.name !== "stand.loose"], free: [false, c.name === "stand.loose"] });
    tubePieces(c.route, c.r, P, { maxLength: 30, spacing: 0.9 }).forEach((piece, index) => put({ name: `${c.name}:${index}`, layer: "back", route: c.name, chunk: index, at: piece.mid, svg: `<g data-mat="rubber">${tubeSvg(piece, { tone: "hi" }).replace('class="tb"', `class="tb" data-hue="${c.hue}"`)}</g>`, shapes: [A.tube(piece.points, piece.radii, { name: `${c.name}:${index}`, owner: c.name })] }));
  }
}

export const T = turning(P, { recorder: R });
const table = T.group("table", { turn: { pivot: [0, 0] } });
const W0 = [-30, 0, 12.5];
const wrist = T.group("wrist", { parent: table, hinge: { point: W0, axis: [0, 1, 0], range: [-3, 3] } });
const thumbBase = T.group("thumb", { parent: wrist, hinge: { point: [-8, -16, 0], axis: [0, 0, 1], range: [-10, 10] } });
const hand = T.layer("hand");
T.stack(["back", "hand", "front"]);

const groupsOf = [];
const H = (group) => ({ group, layer: hand });
const prismAt = (name, group, rect, z0, z1, extra = {}) => T.prism({ name, ...H(group), plan: rect, z: z0, h: z1 - z0, steps: extra.steps ?? 2, tone: extra.tone ?? "mid", ...extra });
const frameOf = (o, a, u) => {
  const axis = G.unit3(a);
  const uu = G.unit3(G.sub3(u, G.mul3(axis, G.dot3(u, axis))));
  return { o, a: axis, u: uu, v: G.cross3(axis, uu) };
};

T.round({ name: "table.platter", ...H(table), F: VERT(0, 0), s0: -4, s1: 0, r: 50, tone: "hi", crease: "lo", material: "steel", details: { ribs: { s0: -3.4, s1: -0.6, count: 160, seams: true, tone: "lo" } } });
T.round({ name: "table.hub", ...H(table), F: VERT(0, 0), s0: 0, s1: 1, r: 8, tone: "mid", material: "chrome" });
T.round({ name: "table.foot", ...H(table), F: VERT(W0[0], W0[1]), s0: 0, s1: 1.2, r: 8, tone: "mid", material: "gunmetal" });
T.round({ name: "table.column", ...H(table), F: VERT(W0[0], W0[1]), s0: 1.2, s1: 9.5, r: 4, tone: "hi", material: "chrome" });
for (let i = 0; i < 8; i++) {
  const t = (i / 8) * Math.PI * 2 + 0.2;
  T.round({ name: `table.bolt.${i}`, ...H(table), F: VERT(42 * Math.cos(t), 42 * Math.sin(t)), s0: 0, s1: 0.6, r: 1.1, tone: "mid", material: "brass" });
}
prismAt("table.term.a", table, { x: -11.5, y: -4.6, w: 2, d: 9.2, r: 0.4 }, 0, 1.3, { material: "gunmetal", bevel: 0.2 });
prismAt("table.term.b", table, { x: -21.5, y: -4.6, w: 2, d: 9.2, r: 0.4 }, 0, 1.3, { material: "gunmetal", bevel: 0.2 });
const tableCables = [];
for (let i = 0; i < 7; i++) {
  const y = -3.6 + i * 1.2;
  tableCables.push(T.tube({ name: `table.cable.${i}`, ...H(table), route: [[-11.5, y, 0.5], [-19.5, y, 0.5]], r: 0.42, chunk: 6, hue: ["red", "green", "blue"][i % 3], material: "rubber", bundle: "table.loom" }));
}

T.round({ name: "wrist.barrel", ...H(wrist), F: frameOf([W0[0], -13.2, W0[2]], [0, 1, 0], [0, 0, 1]), s0: 0, s1: 26.4, r: 2.9, tone: "hi", material: "chrome" });
for (const side of [-1, 1]) T.round({ name: `wrist.cap.${side}`, ...H(wrist), F: frameOf([W0[0], side * 13.2, W0[2]], [0, side, 0], [0, 0, 1]), s0: 0, s1: 0.8, r: 3.4, tone: "mid", material: "gunmetal" });
const PALM = { x: -27, y: -13, w: 36, d: 26, r: 2 };
const PALM_TOP = 14.5;
prismAt("palm.plate", wrist, PALM, 11, PALM_TOP, { steps: 4, bevel: 0.6, tone: "hi", material: "gold", details: { seams: [12.6] } });
prismAt("palm.manifold", wrist, { x: -26.8, y: -12, w: 3, d: 24, r: 0.6 }, PALM_TOP, 17, { material: "gunmetal", bevel: 0.25 });
for (let i = 0; i < 6; i++) prismAt(`palm.valve.${i}`, wrist, { x: -26.4, y: -11 + i * 4, w: 2.2, d: 2.4, r: 0.4 }, 17, 18.2, { material: "brass" });
prismAt("palm.knuckles", wrist, { x: 4, y: -13, w: 5, d: 26, r: 0.8 }, PALM_TOP, 18, { material: "gunmetal", bevel: 0.3 });
for (let i = 0; i < 10; i++) T.round({ name: `palm.bolt.${i}`, ...H(wrist), F: VERT(i % 2 ? 7.6 : 5.4, -11 + Math.floor(i / 2) * 5.5), s0: 18, s1: 18.4, r: 0.45, tone: "mid", material: "steel" });
const FINGER_Y = [-9.6, -3.2, 3.2, 9.6];
for (const [i, yk] of FINGER_Y.entries()) {
  const z = 16.5;
  const F = frameOf([0, yk, z], [1, 0, 0], [0, 0, 1]);
  for (const x of [-20, -12]) prismAt(`palm.ram${i}.saddle.${x}`, wrist, { x, y: yk - 1.2, w: 2, d: 2.4, r: 0.4 }, PALM_TOP, 15.1, { material: "gunmetal" });
  T.round({ name: `palm.ram${i}.cap`, ...H(wrist), F, s0: -22.6, s1: -22, r: 1.55, tone: "mid", material: "steel" });
  T.round({ name: `palm.ram${i}.body`, ...H(wrist), F, s0: -22, s1: -8, r: 1.4, tone: "hi", material: i % 2 ? "copper" : "gunmetal", details: { seams: [-20.5, -9.5] } });
  T.round({ name: `palm.ram${i}.gland`, ...H(wrist), F, s0: -8, s1: -6.6, r: 1.6, tone: "mid", material: "brass" });
  T.round({ name: `palm.ram${i}.rod`, ...H(wrist), F, s0: -6.6, s1: 1, r: 0.6, tone: "hi", material: "chrome" });
  prismAt(`palm.ram${i}.clevis`, wrist, { x: 1, y: yk - 1, w: 2, d: 2, r: 0.3 }, 15.1, 17.9, { material: "steel" });
}
const lanes = [[-12.5, -11.6], [-7.5, -6.4, -5.3], [-1.1, 0, 1.1], [5.3, 6.4, 7.5], [11.6, 12.5]];
const palmCables = [];
lanes.forEach((lane, l) =>
  lane.forEach((y, j) => {
    const r = Math.abs(y) > 11 ? 0.4 : 0.45;
    palmCables.push(T.tube({ name: `palm.cable.${l}.${j}`, ...H(wrist), route: [[-23.8, y, PALM_TOP + r + 0.05], [4, y, PALM_TOP + r + 0.05]], r, chunk: 6, hue: ["red", "green", "blue"][(l + j) % 3], material: "rubber", bundle: `palm.lane.${l}` }));
  }),
);

const LINK = [10, 8, 6];
const RL = 1.5;
function digit(tag, J0, d, w, parent, ranges) {
  const up = G.cross3(d, w);
  const at = (base, along, side, height) => G.add3(base, G.add3(G.mul3(d, along), G.add3(G.mul3(w, side), G.mul3(up, height))));
  const joints = [J0];
  for (const length of LINK) joints.push(G.add3(joints[joints.length - 1], G.mul3(d, length)));
  const groups = [];
  let g = parent;
  for (let i = 0; i < 3; i++) {
    g = T.group(`${tag}.j${i}`, { parent: g, hinge: { point: joints[i], axis: w, range: ranges[i] } });
    groups.push(g);
  }
  groupsOf.push(groups.map((group) => group.name));
  const owners = [parent, groups[0], groups[1]];
  const rect = (group, name, a0, a1, s0, s1, h0, h1, extra = {}) => {
    const F = { o: joints[0], a: up, u: d, v: w };
    void F;
    const o = at(joints[0], 0, 0, 0);
    T.prism({ name, ...H(group), F: { o, a: up, u: d, v: w }, polygon: [[a0, s0], [a1, s0], [a1, s1], [a0, s1]], s0: h0, s1: h1, tone: extra.tone ?? "mid", bevel: extra.bevel ?? 0, material: extra.material ?? "gunmetal" });
  };
  for (let i = 0; i < 3; i++) {
    const J = joints[i];
    const group = groups[i];
    const s = LINK.slice(0, i).reduce((sum, v) => sum + v, 0);
    const e = s + LINK[i];
    for (const side of [-1, 1]) T.round({ name: `${tag}.k${i}${side > 0 ? "+" : "-"}`, ...H(owners[i]), F: frameOf(G.add3(J, G.mul3(w, side * RL)), G.mul3(w, side), up), s0: 0, s1: 1, r: 2.2, tone: "mid", material: "chrome", details: { dots: [{ at: [1, 0, 0], normal: G.mul3(w, side), size: 0.45 }] } });
    T.round({ name: `${tag}.p${i}`, ...H(group), F: frameOf(at(J0, s + 1.8, 0, 0), d, up), s0: 0, s1: LINK[i] - 3.6, r: RL, ends: ["dome", "dome"], tone: "hi", crease: "lo", material: "gold", details: { seams: [0.8, LINK[i] - 4.4] } });
    const a0 = s + 2.4;
    const a1 = e - 2.4;
    for (const side of [-1, 1]) {
      rect(group, `${tag}.p${i}.plate${side > 0 ? "+" : "-"}`, a0, a1, side > 0 ? RL : -RL - 0.6, side > 0 ? RL + 0.6 : -RL, -1.2, 1.2, { material: "gold", tone: "hi", bevel: 0.15 });
      const count = Math.max(1, Math.floor((a1 - a0 - 0.8) / 1.2));
      for (let b = 0; b < count; b++) {
        const along = a0 + 0.8 + ((a1 - a0 - 1.6) * (b + 0.5)) / count;
        T.round({ name: `${tag}.p${i}.boss${side > 0 ? "+" : "-"}${b}`, ...H(group), F: frameOf(at(J0, along, side * (RL + 0.6), 0), G.mul3(w, side), up), s0: 0, s1: 0.4, r: 0.4, tone: "mid", material: "steel" });
      }
    }
    rect(group, `${tag}.p${i}.spine`, a0, a1, -0.9, 0.9, RL, RL + 0.8, { material: "gunmetal", bevel: 0.15 });
    const blocks = Math.max(1, Math.floor((a1 - a0 - 1.2) / 1.6));
    for (let b = 0; b < blocks; b++) {
      const c = a0 + 0.6 + ((a1 - a0 - 1.2) * (b + 0.5)) / blocks;
      rect(group, `${tag}.p${i}.block${b}`, c - 0.5, c + 0.5, -0.6, 0.6, RL + 0.8, RL + 1.5, { material: b % 2 ? "brass" : "steel" });
    }
    rect(group, `${tag}.p${i}.pad`, s + 2.6, e - 2.6, -0.8, 0.8, -RL - 0.6, -RL, { material: "rubber" });
    const cableSides = i === 0 ? [-1, 1] : i === 1 ? [-1, 1] : [];
    for (const side of cableSides) {
      const y0 = side * 1.8;
      rect(group, `${tag}.p${i}.clip${side > 0 ? "+" : "-"}0`, a0, a0 + 0.6, side > 0 ? RL : -RL - 0.6, side > 0 ? RL + 0.6 : -RL, 1.2, 2.0, { material: "brass" });
      rect(group, `${tag}.p${i}.clip${side > 0 ? "+" : "-"}1`, a1 - 0.6, a1, side > 0 ? RL : -RL - 0.6, side > 0 ? RL + 0.6 : -RL, 1.2, 2.0, { material: "brass" });
      T.tube({ name: `${tag}.p${i}.cable${side > 0 ? "+" : "-"}`, ...H(group), route: [at(J0, a0 + 0.6, y0, 1.55), at(J0, a1 - 0.6, y0, 1.55)], r: 0.35, chunk: 6, hue: side > 0 ? "red" : "blue", material: "rubber" });
    }
  }
  return groups;
}

const FZ = 16.25;
for (const [i, yk] of FINGER_Y.entries()) digit(`f${i}`, [11.5, yk, FZ], [1, 0, 0], [0, 1, 0], wrist, [[-5, 5], [-5, 5], [-5, 5]]);
T.round({ name: "thumb.turret", ...H(thumbBase), F: VERT(-8, -16), s0: 11, s1: 16.5, r: 3, tone: "hi", material: "chrome" });
prismAt("thumb.arm", thumbBase, { x: -9.4, y: -21.6, w: 2.8, d: 2.55, r: 0.3 }, 12.5, 15, { material: "gunmetal" });
digit("thumb", [-8, -21.5, 13.75], [0, -1, 0], [1, 0, 0], thumbBase, [[-5, 5], [-5, 5], [-5, 5]]);

const flexAt = (time, phase) => 0.5 + 0.5 * Math.sin(time * 1.3 + phase);
export const follow = (values) => {
  const out = { ...values };
  const lift = values.lift ?? 0;
  const t = values.time ?? 0;
  out.wrist = 3 * Math.sin(t * 0.7) * lift;
  out.thumb = 10 * Math.sin(t * 0.5) * lift;
  for (let f = 0; f < 5; f++) {
    const tag = f < 4 ? `f${f}` : "thumb";
    const curl = (Math.sin(t * 1.3 + f * 0.9) * 0.5 + 0.5) * lift;
    out[`${tag}.j0`] = -5 + 10 * curl;
    out[`${tag}.j1`] = -5 + 10 * curl;
    out[`${tag}.j2`] = -5 + 10 * curl;
  }
  return out;
};
void flexAt;
export const poses = () => {
  const out = [];
  for (let i = 0; i < 720; i++) out.push(follow({ table: i * 0.5, lift: 1, time: i * 0.11 }));
  for (let i = 0; i < 72; i++) out.push(follow({ table: i * 5, lift: 0 }));
  return out;
};

workshop();
const started = Date.now();
const out = T.build({ statics: R.items, poses, verify: argv.includes("--verify") });
A.settle(R, P);
if (argv.includes("--audit")) {
  A.auditOrExit(R, P);
  TA.orbitOrExit(T, R, P, {
    sweep: { at: (x) => follow({ table: x, lift: 0 }), from: 0, to: 360, frame: 0.3 },
    clips: [{ name: "flex", at: (t) => follow({ table: 30 + 18 * t, lift: 1, time: t }), from: 0, to: 6, frame: 1 / 30 }],
    quick: argv.includes("--quick"),
    only: argv.find((a) => a.startsWith("--only="))?.slice(7).split(",") ?? null,
    log: argv.includes("--verbose") ? (text) => console.log(`  … ${text}`) : null,
  });
}
const layerOf = (name) => R.items.filter((item) => item.layer === name).sort((a, b) => a.key - b.key).map((item) => item.svg);
const BACK = [`<path class="iso-halo" d="${k.haloOf(BENCH, BENCH_TOP - 6, 6, P)}"/>`, ...layerOf("back")];
const backSvg = k.figureSvg({ width: WIDTH, height: HEIGHT, label: "A dense robot hand on a turntable in a workshop: a stress scene for the 3D mode.", body: BACK });
const staticSvg = (list) => `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg iso-layer" viewBox="0 0 ${WIDTH} ${HEIGHT}" aria-hidden="true">${list.join("")}</svg>`;
const stage = `<div class="iso-stage iso-turn ts-stage" tabindex="0" role="img" aria-label="A dense robot hand turning on a turntable">${backSvg}${out.layerSvg("hand", { width: WIDTH, height: HEIGHT })}${staticSvg(layerOf("front"))}</div>`;
const LIVE = `const DATA = ${JSON.stringify(out.data)};
const follow = ${follow.toString()};
const stage = document.querySelector(".ts-stage");
const still = matchMedia("(prefers-reduced-motion: reduce)");
let touring = !still.matches;
let angle = 0;
let time = 0;
let last = 0;
let frame = 0;
let visible = false;
let slow = 0;
let skip = false;
const controller = TURN.mount(stage, DATA, { follow, onHold: () => (touring = false) });
function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  if (touring) {
    angle += 14 * dt;
    time += dt;
  }
  skip = !skip;
  if (!(slow > 14 && skip)) {
    const t0 = performance.now();
    controller.set({ table: angle, lift: 1, time });
    slow = slow * 0.9 + (performance.now() - t0) * 0.1;
    if (slow < 9) skip = false;
  }
  frame = visible && touring ? requestAnimationFrame(tick) : 0;
}
new IntersectionObserver((entries) => {
  visible = entries[entries.length - 1].isIntersecting;
  if (visible && touring && !frame) frame = requestAnimationFrame(tick);
}).observe(stage);
`;
const body = k.plateHtml({ fig: "Fig 3D·S", title: "Stress hand", hint: "Tour only", readout: `${out.report.parts} live parts`, caption: "A stress scene for the 3D mode: a dense hand of hinge chains on a turntable among static workshop clutter.", body: `<style>${out.css}\n.ts-stage{outline:none}</style>${stage}` });
const page = k.pageHtml({ title: "Stress hand", theme, body, script: turnScript() + (out.verify ? `window.__isoVerify = ${JSON.stringify(out.verify)};\n` : "") + LIVE, width: WIDTH + 40 });
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  const name = `turning-stress${out.verify ? "-verify" : ""}${theme === "light" ? "-light" : ""}.html`;
  writeFileSync(join(HERE, name), page);
  const routes = T.routes.length;
  console.log(`wrote ${name} · ${(page.length / 1024).toFixed(0)} KB · ${out.report.parts} parts · ${routes} routes · ${T.groups.length} groups · ${out.report.layers.map((l) => `${l.name}: ${l.planar} planes, ${l.dynamic} dynamic`).join(" · ")} · ${out.report.proofs.length} layer proofs · data ${(JSON.stringify(out.data).length / 1024).toFixed(0)} KB · build ${((Date.now() - started) / 1000).toFixed(1)} s`);
}
export { out };
```

## examples/turning-dial/turning-dial-light.html

```

```

## examples/turning-dial/turning-dial-verify-light.html

```

```

## examples/turning-dial/turning-dial.html

```

```

## examples/turning-dial/turning-stress-light.html

```

```

## kit

```

```

## kit/audit.mjs

```js
import { cameraOf, roundedPlan } from "./iso-kit.mjs";
import { add3, sub3, mul3, dot3, len3, unit3, pathLength, resample, curveRuns, cut as cutAlong, tangentAlong } from "./lathe.mjs";
import { runsOf } from "./tube.mjs";

const BIG = 1e9;

export function body(F, poly, meta = {}) {
  return { kind: "body", F, poly: poly.map(([s, r]) => [s, Math.max(0, r)]), ...meta };
}

export function traced(meridian, { radius = null, error = 0.02, depth = 6 } = {}) {
  const segments = radius ? null : curveRuns(meridian);
  const out = [[meridian[0][0], meridian[0][1]]];
  for (let index = 0; index < meridian.length - 1; index++) {
    const [s0] = meridian[index];
    const [s1, r1] = meridian[index + 1];
    const along = Math.abs(s1 - s0) > 1e-9;
    const at = along && radius ? radius : along && segments[index] ? segments[index].radius : null;
    if (at) {
      const split = (a, b, level) => {
        const mid = (a + b) / 2;
        if (level >= depth || Math.abs(at(mid) - (at(a) + at(b)) / 2) <= error) return;
        split(a, mid, level + 1);
        out.push([mid, at(mid)]);
        split(mid, b, level + 1);
      };
      split(s0, s1, 0);
    }
    out.push([s1, r1]);
  }
  return out;
}

export function lathe(F, meridian, { radius = null, ...meta } = {}) {
  return body(F, traced(meridian, { radius }), meta);
}

export function solid(F, profile, { radius = null, ...meta } = {}) {
  const first = profile[0];
  const last = profile[profile.length - 1];
  return body(F, traced([[first[0], 0], ...profile, [last[0], 0]], { radius: radius && ((s) => radius(Math.max(first[0], Math.min(last[0], s)))) }), meta);
}

export function prism(F, polygon, s0, s1, meta = {}) {
  return { kind: "prism", F, poly: polygon.map(([x, y]) => [x, y]), s0: Math.min(s0, s1), s1: Math.max(s0, s1), ...meta };
}

const FLOOR = { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] };

export const extruded = (ring, z, height, meta = {}) => prism(FLOOR, ring, z, z + height, meta);

export const slab = (plan, z, height, meta = {}, steps = 8) => extruded(roundedPlan(plan, steps), z, height, meta);

export const cylinder = (cx, cy, r, z, height, meta = {}) => disc({ o: [cx, cy, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, z, z + height, r, meta);

export const disc = (F, s0, s1, r, meta = {}) => body(F, [[s0, 0], [s0, r], [s1, r], [s1, 0]], meta);

export const ring = (F, s0, s1, rIn, rOut, meta = {}) => body(F, [[s0, rIn], [s0, rOut], [s1, rOut], [s1, rIn]], meta);

export function box(min, max, meta = {}) {
  const centre = mul3(add3(min, max), 0.5);
  return { kind: "box", o: centre, axes: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], half: mul3(sub3(max, min), 0.5), ...meta };
}

export const orientedBox = (centre, axes, half, meta = {}) => ({ kind: "box", o: centre, axes: axes.map(unit3), half, ...meta });

export const ball = (at, r, meta = {}) => ({ kind: "ball", o: at, r, ...meta });

export function tube(points, radius, meta = {}) {
  const total = pathLength(points) || 1;
  let walked = 0;
  const radii = Array.isArray(radius)
    ? radius
    : points.map((point, index) => {
        if (index > 0) walked += len3(sub3(point, points[index - 1]));
        return typeof radius === "function" ? radius(walked / total) : radius;
      });
  return { kind: "tube", points, radii, round: meta.round ?? [false, false], ...meta };
}

export const slide = (F, from, to, r, meta = {}) => ({ ...disc(F, from, to, r, meta), slide: { from, to, r } });

export function moved(shape, offset) {
  if (!offset || (!offset[0] && !offset[1] && !offset[2])) return shape;
  if (shape.slide) {
    const { from, to, r } = shape.slide;
    const reach = Math.max(from + 1e-3, to + dot3(offset, shape.F.a));
    return { ...shape, poly: [[from, 0], [from, r], [reach, r], [reach, 0]], slide: { from, to: reach, r }, _b: undefined, _f: undefined };
  }
  const cut = shape.cut ? (Array.isArray(shape.cut) ? shape.cut.map((one) => moved(one, offset)) : moved(shape.cut, offset)) : undefined;
  if (shape.kind === "body" || shape.kind === "prism") return { ...shape, cut, F: { ...shape.F, o: add3(shape.F.o, offset) }, _b: undefined, _f: undefined };
  if (shape.kind === "tube") return { ...shape, cut, points: shape.points.map((p) => add3(p, offset)), tips: shape.tips && shape.tips.map((tip) => tip && add3(tip, offset)), _b: undefined };
  return { ...shape, cut, o: add3(shape.o, offset), _b: undefined };
}

function polyDistance(poly, s, r) {
  let best = BIG;
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [s0, r0] = poly[j];
    const [s1, r1] = poly[i];
    const ds = s1 - s0;
    const dr = r1 - r0;
    const span = ds * ds + dr * dr;
    const t = span > 0 ? Math.max(0, Math.min(1, ((s - s0) * ds + (r - r0) * dr) / span)) : 0;
    const es = s - s0 - ds * t;
    const er = r - r0 - dr * t;
    const d = es * es + er * er;
    if (d < best && (r0 > 1e-9 || r1 > 1e-9)) best = d;
    if (r0 > r !== r1 > r && s < s0 + ((r - r0) * ds) / (dr || 1e-12)) inside = !inside;
  }
  const d = Math.sqrt(best);
  return inside ? -d : d;
}

function polygonDistance(poly, x, y) {
  let best = BIG;
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [x0, y0] = poly[j];
    const [x1, y1] = poly[i];
    const dx = x1 - x0;
    const dy = y1 - y0;
    const span = dx * dx + dy * dy;
    const t = span > 0 ? Math.max(0, Math.min(1, ((x - x0) * dx + (y - y0) * dy) / span)) : 0;
    const ex = x - x0 - dx * t;
    const ey = y - y0 - dy * t;
    const d = ex * ex + ey * ey;
    if (d < best) best = d;
    if (y0 > y !== y1 > y && x < x0 + ((y - y0) * dx) / (dy || 1e-12)) inside = !inside;
  }
  const d = Math.sqrt(best);
  return inside ? -d : d;
}

function cappedSegment(p, a, b, r) {
  const bx = b[0] - a[0];
  const by = b[1] - a[1];
  const bz = b[2] - a[2];
  const px = p[0] - a[0];
  const py = p[1] - a[1];
  const pz = p[2] - a[2];
  const baba = bx * bx + by * by + bz * bz || 1e-12;
  const paba = px * bx + py * by + pz * bz;
  const x = Math.hypot(px * baba - bx * paba, py * baba - by * paba, pz * baba - bz * paba) - r * baba;
  const y = Math.abs(paba - baba * 0.5) - baba * 0.5;
  const x2 = x * x;
  const y2 = y * y * baba;
  const d = Math.max(x, y) < 0 ? -Math.min(x2, y2) : (x > 0 ? x2 : 0) + (y > 0 ? y2 : 0);
  return (Math.sign(d) * Math.sqrt(Math.abs(d))) / baba;
}

function segmentPoint(p, a, b) {
  const ba = sub3(b, a);
  const pa = sub3(p, a);
  const t = Math.max(0, Math.min(1, dot3(pa, ba) / (dot3(ba, ba) || 1e-12)));
  return { d: len3(sub3(pa, mul3(ba, t))), t };
}

export function sdf(shape, p) {
  const d = baseSdf(shape, p);
  return shape.cut ? Math.max(d, -cutDistance(shape, p)) : d;
}

function cutDistance(shape, p) {
  if (!Array.isArray(shape.cut)) return sdf(shape.cut, p);
  let best = BIG;
  for (const cut of shape.cut) best = Math.min(best, sdf(cut, p));
  return best;
}

function baseSdf(shape, p) {
  if (shape.kind === "body") {
    const { F } = shape;
    const q = sub3(p, F.o);
    const s = dot3(q, F.a);
    const x = dot3(q, F.u);
    const y = dot3(q, F.v);
    return polyDistance(shape.poly, s, Math.hypot(x, y));
  }
  if (shape.kind === "ball") {
    let d = len3(sub3(p, shape.o)) - shape.r;
    if (shape.flats) for (const [nx, ny, nz, cut] of shape.flats) d = Math.max(d, nx * (p[0] - shape.o[0]) + ny * (p[1] - shape.o[1]) + nz * (p[2] - shape.o[2]) - cut);
    return d;
  }
  if (shape.kind === "prism") {
    const { F } = shape;
    const q = sub3(p, F.o);
    const s = dot3(q, F.a);
    const flat = polygonDistance(shape.poly, dot3(q, F.u), dot3(q, F.v));
    const along = Math.max(shape.s0 - s, s - shape.s1);
    return Math.hypot(Math.max(flat, 0), Math.max(along, 0)) + Math.min(Math.max(flat, along), 0);
  }
  if (shape.kind === "box") {
    const q = sub3(p, shape.o);
    const d = shape.axes.map((axis, index) => Math.abs(dot3(q, axis)) - shape.half[index]);
    const outside = Math.hypot(Math.max(d[0], 0), Math.max(d[1], 0), Math.max(d[2], 0));
    return outside + Math.min(Math.max(d[0], d[1], d[2]), 0);
  }
  const { points, radii, round } = shape;
  let best = BIG;
  const last = points.length - 1;
  for (let index = 0; index < last; index++) {
    const r = (radii[index] + radii[index + 1]) / 2;
    const d = cappedSegment(p, points[index], points[index + 1], r);
    if (d < best) best = d;
  }
  if (round[0]) best = Math.min(best, len3(sub3(p, points[0])) - radii[0]);
  if (round[1]) best = Math.min(best, len3(sub3(p, points[last])) - radii[last]);
  return best;
}

export function boundsOf(shape) {
  if (shape._b) return shape._b;
  let min;
  let max;
  if (shape.kind === "body") {
    const { F, poly } = shape;
    const s0 = Math.min(...poly.map(([s]) => s));
    const s1 = Math.max(...poly.map(([s]) => s));
    const rm = Math.max(...poly.map(([, r]) => r));
    const a = add3(F.o, mul3(F.a, s0));
    const b = add3(F.o, mul3(F.a, s1));
    const spread = [0, 1, 2].map((i) => rm * Math.sqrt(Math.max(0, 1 - F.a[i] * F.a[i])));
    min = [0, 1, 2].map((i) => Math.min(a[i], b[i]) - spread[i]);
    max = [0, 1, 2].map((i) => Math.max(a[i], b[i]) + spread[i]);
  } else if (shape.kind === "ball") {
    min = shape.o.map((v) => v - shape.r);
    max = shape.o.map((v) => v + shape.r);
  } else if (shape.kind === "prism") {
    const { F } = shape;
    min = [BIG, BIG, BIG];
    max = [-BIG, -BIG, -BIG];
    for (const s of [shape.s0, shape.s1])
      for (const [x, y] of shape.poly) {
        const p = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
        for (let i = 0; i < 3; i++) (min[i] = Math.min(min[i], p[i])), (max[i] = Math.max(max[i], p[i]));
      }
  } else if (shape.kind === "box") {
    const extent = [0, 1, 2].map((i) => shape.axes.reduce((sum, axis, j) => sum + Math.abs(axis[i]) * shape.half[j], 0));
    min = shape.o.map((v, i) => v - extent[i]);
    max = shape.o.map((v, i) => v + extent[i]);
  } else {
    min = [BIG, BIG, BIG];
    max = [-BIG, -BIG, -BIG];
    shape.points.forEach((p, index) => {
      for (let i = 0; i < 3; i++) {
        min[i] = Math.min(min[i], p[i] - shape.radii[index]);
        max[i] = Math.max(max[i], p[i] + shape.radii[index]);
      }
    });
  }
  shape._b = { min, max };
  return shape._b;
}

export function cameraBasis(P) {
  const { sinA, cosA, sinE, cosE, k } = cameraOf(P);
  return { R: [sinA, -cosA, 0], D: [cosA * sinE, sinA * sinE, -cosE], V: [cosA * cosE, sinA * cosE, sinE], k, origin: P.origin };
}

function screenBox(shape, cam, offset) {
  const { min, max } = boundsOf(shape);
  let x0 = BIG;
  let y0 = BIG;
  let x1 = -BIG;
  let y1 = -BIG;
  const visit = (p) => {
    const x = cam.origin[0] + dot3(p, cam.R) * cam.k;
    const y = cam.origin[1] + dot3(p, cam.D) * cam.k;
    x0 = Math.min(x0, x);
    y0 = Math.min(y0, y);
    x1 = Math.max(x1, x);
    y1 = Math.max(y1, y);
  };
  if (shape.kind === "tube") {
    shape.points.forEach((p, index) => {
      const r = shape.radii[index];
      const q = add3(p, offset);
      visit(add3(q, mul3(cam.R, r)));
      visit(add3(q, mul3(cam.R, -r)));
      visit(add3(q, mul3(cam.D, r)));
      visit(add3(q, mul3(cam.D, -r)));
    });
  } else {
    for (const x of [min[0], max[0]]) for (const y of [min[1], max[1]]) for (const z of [min[2], max[2]]) visit(add3([x, y, z], offset));
  }
  return [x0, y0, x1, y1];
}

function spanOf(c, V, min, max) {
  let t0 = -BIG;
  let t1 = BIG;
  for (let i = 0; i < 3; i++) {
    if (Math.abs(V[i]) < 1e-12) {
      if (c[i] < min[i] || c[i] > max[i]) return null;
      continue;
    }
    let a = (min[i] - c[i]) / V[i];
    let b = (max[i] - c[i]) / V[i];
    if (a > b) [a, b] = [b, a];
    t0 = Math.max(t0, a);
    t1 = Math.min(t1, b);
  }
  return t0 <= t1 ? [t0, t1] : null;
}

function tubeDistance(shape, p, segments) {
  const { points, radii, round } = shape;
  const last = points.length - 1;
  let best = BIG;
  for (const index of segments) {
    const d = cappedSegment(p, points[index], points[index + 1], (radii[index] + radii[index + 1]) / 2);
    if (d < best) best = d;
    for (const [joint, flag] of [[0, round[0]], [last, round[1]]]) {
      if (!flag || (joint !== index && joint !== index + 1)) continue;
      const q = points[joint];
      const e = Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) - radii[joint];
      if (e < best) best = e;
    }
  }
  return best;
}

function bodyDepth(shape, c, V, eps) {
  const { F, poly } = shape;
  if (!shape._f) {
    const s = poly.map(([value]) => value);
    shape._f = { aV: dot3(F.a, V), uV: dot3(F.u, V), vV: dot3(F.v, V), s0: Math.min(...s), s1: Math.max(...s), rm: Math.max(...poly.map(([, r]) => r)) };
  }
  const { aV, uV, vV, s0, s1, rm } = shape._f;
  const q = [c[0] - F.o[0], c[1] - F.o[1], c[2] - F.o[2]];
  const sc = dot3(q, F.a);
  const xc = dot3(q, F.u);
  const yc = dot3(q, F.v);
  let lo = -BIG;
  let hi = BIG;
  if (Math.abs(aV) > 1e-9) {
    const a = (s0 - sc) / aV;
    const b = (s1 - sc) / aV;
    lo = Math.min(a, b);
    hi = Math.max(a, b);
  } else if (sc < s0 || sc > s1) return null;
  const qa = uV * uV + vV * vV;
  if (qa > 1e-12) {
    const qb = 2 * (xc * uV + yc * vV);
    const qc = xc * xc + yc * yc - rm * rm;
    const disc = qb * qb - 4 * qa * qc;
    if (disc < 0) return null;
    const root = Math.sqrt(disc);
    lo = Math.max(lo, (-qb - root) / (2 * qa));
    hi = Math.min(hi, (-qb + root) / (2 * qa));
  } else if (xc * xc + yc * yc > rm * rm) return null;
  if (lo > hi) return null;
  let t = hi + eps;
  for (let step = 0; step < 160 && t >= lo - eps; step++) {
    let d = polyDistance(poly, sc + t * aV, Math.hypot(xc + t * uV, yc + t * vV));
    if (shape.cut) d = Math.max(d, -cutDistance(shape, [c[0] + V[0] * t, c[1] + V[1] * t, c[2] + V[2] * t]));
    if (d < eps) return t;
    t -= Math.max(d, eps * 0.5);
  }
  return null;
}

function rayDepth(shape, c, V, eps, segments) {
  if (shape.kind === "body") return bodyDepth(shape, c, V, eps);
  const span = spanOf(c, V, boundsOf(shape).min, boundsOf(shape).max);
  if (!span) return null;
  let t = span[1] + eps;
  for (let step = 0; step < 160 && t >= span[0] - eps; step++) {
    const p = [c[0] + V[0] * t, c[1] + V[1] * t, c[2] + V[2] * t];
    const d = segments ? tubeDistance(shape, p, segments) : sdf(shape, p);
    if (d < eps) return t;
    t -= Math.max(d, eps * 0.5);
  }
  return null;
}

function flatDistance(x, y, a, b) {
  const dx = b[0] - a[0];
  const dy = b[1] - a[1];
  const span = dx * dx + dy * dy;
  const t = span > 0 ? Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / span)) : 0;
  return Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t);
}

export const shareOf = (item, state) => (typeof state === "number" ? state : state.shares[item.ride ?? item.piece] ?? 0);

export const offsetOf = (item, state) => mul3(item.move ?? [0, 0, 0], shareOf(item, state));

export const labelOf = (state) => (typeof state === "number" ? `share ${Math.round(state * 1000) / 1000}` : state.label);

export function springStates(order, { stagger = 0.035, stiffness = 64, damping = 14.2, substeps = 4, fps = 60, settle = 1e-3, stride = 1, reversals = [] } = {}) {
  const out = [];
  const scripts = [[true], [false], ...reversals.flatMap((at) => [[true, at], [false, at]])];
  for (const [on, back] of scripts) {
    const springs = Object.fromEntries(order.map((id) => [id, { x: on ? 0 : 1, v: 0, target: on ? 0 : 1, pending: null }]));
    const toggle = (value, clock) => {
      const goal = value ? 1 : 0;
      let index = 0;
      for (const id of value ? order : [...order].reverse()) {
        if (springs[id].target === goal) springs[id].pending = null;
        else springs[id].pending = { value: goal, at: clock + index++ * stagger };
      }
    };
    const name = back === undefined ? (on ? "apart" : "together") : `${on ? "apart" : "together"}, back at ${back.toFixed(2)} s,`;
    const dt = 1 / fps;
    let clock = 0;
    let goal = on ? 1 : 0;
    toggle(on, 0);
    for (let frame = 0; frame < 100000; frame++) {
      if (back !== undefined && clock < back && clock + dt >= back) toggle(!on, back), (goal = on ? 0 : 1);
      clock += dt;
      for (const spring of Object.values(springs)) {
        if (spring.pending && clock >= spring.pending.at) (spring.target = spring.pending.value), (spring.pending = null);
        for (let index = 0; index < substeps; index++) {
          const h = dt / substeps;
          spring.v += ((spring.target - spring.x) * stiffness - spring.v * damping) * h;
          spring.x += spring.v * h;
        }
      }
      const settled = (back === undefined || clock >= back) && Object.values(springs).every((spring) => !spring.pending && Math.abs(spring.x - goal) < settle && Math.abs(spring.v) < settle);
      if (settled) break;
      if ((frame + 1) % stride === 0) out.push({ label: `${name} ${clock.toFixed(3)} s`, shares: Object.fromEntries(Object.entries(springs).map(([id, spring]) => [id, spring.x])) });
    }
  }
  return out;
}

export const uniformStates = (count = 10) => Array.from({ length: count + 1 }, (_, index) => index / count);

export const reversalsOf = (order, stagger = 0.035, margin = 0.008) => order.flatMap((_, index) => [index * stagger - margin, index * stagger + margin]).filter((at) => at > 0);

export function prefixStates(order, { shares = [0.25, 0.5, 0.75, 1] } = {}) {
  const out = [];
  for (let count = 1; count < order.length; count++)
    for (const s of shares)
      out.push({ label: `first ${count - 1} out, ${order[count - 1]} at ${s}`, shares: Object.fromEntries(order.map((id, index) => [id, index < count - 1 ? 1 : index === count - 1 ? s : 0])) });
  return out;
}

function rasterOf(item, cam, step, offset) {
  const shapes = item.shapes.map((shape) => moved(shape, offset));
  const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
  const boxes = shapes.map((shape) => screenBox(shape, cam, [0, 0, 0]));
  const guides = shapes.map((shape) => {
    if (shape.kind === "tube") {
      const points = shape.points.map(flat);
      const widths = shape.radii.map((r) => r * cam.k);
      const boxes = new Float64Array((points.length - 1) * 4);
      for (let i = 0; i < points.length - 1; i++) {
        const pad = Math.max(widths[i], widths[i + 1]) + step * 0.5;
        boxes[i * 4] = Math.min(points[i][0], points[i + 1][0]) - pad;
        boxes[i * 4 + 1] = Math.max(points[i][0], points[i + 1][0]) + pad;
        boxes[i * 4 + 2] = Math.min(points[i][1], points[i + 1][1]) - pad;
        boxes[i * 4 + 3] = Math.max(points[i][1], points[i + 1][1]) + pad;
      }
      return { points, widths, boxes };
    }
    if (shape.kind === "body") {
      const s = shape.poly.map(([value]) => value);
      return { axis: [flat(add3(shape.F.o, mul3(shape.F.a, Math.min(...s)))), flat(add3(shape.F.o, mul3(shape.F.a, Math.max(...s))))], reach: Math.max(...shape.poly.map(([, r]) => r)) * cam.k };
    }
    return null;
  });
  const x0 = Math.floor(Math.min(...boxes.map((b) => b[0])) / step);
  const y0 = Math.floor(Math.min(...boxes.map((b) => b[1])) / step);
  const x1 = Math.ceil(Math.max(...boxes.map((b) => b[2])) / step);
  const y1 = Math.ceil(Math.max(...boxes.map((b) => b[3])) / step);
  const w = x1 - x0 + 1;
  const h = y1 - y0 + 1;
  return { x0, y0, x1, y1, w, h, boxes, shapes, guides, depth: null };
}

function depthAt(raster, cam, step, cx, cy) {
  if (!raster.depth) {
    raster.depth = new Float32Array(raster.w * raster.h);
    raster.done = new Uint8Array(raster.w * raster.h);
  }
  const cell = (cy - raster.y0) * raster.w + (cx - raster.x0);
  if (raster.done[cell]) return raster.depth[cell];
  const x = cx * step;
  const y = cy * step;
  const c = add3(mul3(cam.R, (x - cam.origin[0]) / cam.k), mul3(cam.D, (y - cam.origin[1]) / cam.k));
  let best = NaN;
  for (let index = 0; index < raster.shapes.length; index++) {
    const shape = raster.shapes[index];
    const b = raster.boxes[index];
    if (x < b[0] || x > b[2] || y < b[1] || y > b[3]) continue;
    const guide = raster.guides[index];
    let segments = null;
    if (shape.kind === "tube") {
      segments = [];
      const boxes = guide.boxes;
      for (let i = 0; i < guide.points.length - 1; i++) {
        if (x < boxes[i * 4] || x > boxes[i * 4 + 1] || y < boxes[i * 4 + 2] || y > boxes[i * 4 + 3]) continue;
        if (flatDistance(x, y, guide.points[i], guide.points[i + 1]) <= Math.max(guide.widths[i], guide.widths[i + 1]) + step * 0.5) segments.push(i);
      }
      if (!segments.length) continue;
    } else if (shape.kind === "body" && flatDistance(x, y, guide.axis[0], guide.axis[1]) > guide.reach + step) continue;
    const t = rayDepth(shape, c, cam.V, 0.02, segments);
    if (t !== null && !(t <= best)) best = t;
  }
  raster.depth[cell] = best;
  raster.done[cell] = 1;
  return best;
}

function regionOf(flags, gw, gh, value, step) {
  const n = gw * gh;
  const on = new Uint8Array(n);
  let all = 0;
  let inner = 0;
  for (let cell = 0; cell < n; cell++) if (flags[cell] === value) (on[cell] = 1), all++;
  if (!all) return { all, inner, area: 0 };
  const grow = new Uint8Array(n);
  for (let y = 0; y < gh; y++)
    for (let x = 0; x < gw; x++) {
      const cell = y * gw + x;
      if (on[cell] && x > 0 && x < gw - 1 && y > 0 && y < gh - 1 && on[cell - 1] && on[cell + 1] && on[cell - gw] && on[cell + gw]) inner++;
      if (on[cell] || (x > 0 && on[cell - 1]) || (x < gw - 1 && on[cell + 1]) || (y > 0 && on[cell - gw]) || (y < gh - 1 && on[cell + gw])) grow[cell] = 1;
    }
  const shut = new Uint8Array(n);
  for (let y = 0; y < gh; y++)
    for (let x = 0; x < gw; x++) {
      const cell = y * gw + x;
      shut[cell] = on[cell] || (grow[cell] && (x === 0 || grow[cell - 1]) && (x === gw - 1 || grow[cell + 1]) && (y === 0 || grow[cell - gw]) && (y === gh - 1 || grow[cell + gw])) ? 1 : 0;
    }
  const core = new Uint8Array(n);
  for (let y = 1; y < gh - 1; y++)
    for (let x = 1; x < gw - 1; x++) {
      const cell = y * gw + x;
      core[cell] = shut[cell] && shut[cell - 1] && shut[cell + 1] && shut[cell - gw] && shut[cell + gw] ? 1 : 0;
    }
  const seen = new Uint8Array(n);
  const stack = new Int32Array(n);
  let largest = 0;
  for (let start = 0; start < n; start++) {
    if (!core[start] || seen[start]) continue;
    let top = 0;
    let size = 0;
    stack[top++] = start;
    seen[start] = 1;
    while (top) {
      const cell = stack[--top];
      size++;
      const x = cell % gw;
      const y = (cell - x) / gw;
      const visit = (next) => {
        if (core[next] && !seen[next]) (seen[next] = 1), (stack[top++] = next);
      };
      if (x > 0) visit(cell - 1);
      if (x < gw - 1) visit(cell + 1);
      if (y > 0) visit(cell - gw);
      if (y < gh - 1) visit(cell + gw);
    }
    if (size > largest) largest = size;
  }
  return { all, inner, area: largest * step * step };
}

function measure(ra, rb, dx, dy, dz, cam, step, tolerance, least) {
  if (ra.x1 < rb.x0 + dx || rb.x1 + dx < ra.x0 || ra.y1 < rb.y0 + dy || rb.y1 + dy < ra.y0) return null;
  const ix0 = Math.max(ra.x0, rb.x0 + dx);
  const ix1 = Math.min(ra.x1, rb.x1 + dx);
  const iy0 = Math.max(ra.y0, rb.y0 + dy);
  const iy1 = Math.min(ra.y1, rb.y1 + dy);
  const gw = ix1 - ix0 + 1;
  const gh = iy1 - iy0 + 1;
  const flags = new Int8Array(gw * gh);
  const worst = [0, 0];
  const at = [null, null];
  let any = false;
  for (let y = iy0; y <= iy1; y++) {
    for (let x = ix0; x <= ix1; x++) {
      const ta = depthAt(ra, cam, step, x, y);
      if (Number.isNaN(ta)) continue;
      const tb = depthAt(rb, cam, step, x - dx, y - dy) + dz;
      if (Number.isNaN(tb)) continue;
      const cell = (y - iy0) * gw + (x - ix0);
      if (ta > tb + tolerance) {
        flags[cell] = 1;
        any = true;
        if (ta - tb > worst[0]) (worst[0] = ta - tb), (at[0] = [x * step, y * step]);
      } else if (tb > ta + tolerance) {
        flags[cell] = -1;
        any = true;
        if (tb - ta > worst[1]) (worst[1] = tb - ta), (at[1] = [x * step, y * step]);
      }
    }
  }
  if (!any) return null;
  const front = [regionOf(flags, gw, gh, 1, step), regionOf(flags, gw, gh, -1, step)];
  if (front[0].area < least && front[1].area < least) return null;
  return front.map((region, side) => ({ ...region, depth: worst[side], at: at[side] }));
}

export function overlapsOf(items, P, { step = 0.25, tolerance = 0.3, least = 0.25, states = [0], skip = () => false } = {}) {
  const cam = cameraBasis(P);
  const live = items.map((item, index) => ({ item, index })).filter(({ item }) => item.shapes && item.shapes.length);
  const homes = live.map(() => null);
  let uid = 0;
  const stamp = (raster) => Object.assign(raster, { uid: uid++ });
  const home = (index) => homes[index] ?? (homes[index] = stamp(rasterOf(live[index].item, cam, step, [0, 0, 0])));
  const sliding = live.map(({ item }) => item.shapes.some((shape) => shape.slide));
  const slid = new Map();
  const rasterAt = (index, offset) => {
    if (!sliding[index]) return home(index);
    const id = `${index}|${offset.map((v) => v.toFixed(2)).join(",")}`;
    if (!slid.has(id)) slid.set(id, stamp(rasterOf(live[index].item, cam, step, offset)));
    return slid.get(id);
  };
  const shiftOf = (offset) => ({ gx: Math.round((dot3(offset, cam.R) * cam.k) / step), gy: Math.round((dot3(offset, cam.D) * cam.k) / step), dz: dot3(offset, cam.V) });
  const memo = new Map();
  const pairs = [];
  for (const state of states) {
    const offsets = live.map(({ item }) => offsetOf(item, state));
    const shifts = live.map((_, index) => (sliding[index] ? { gx: 0, gy: 0, dz: 0 } : shiftOf(offsets[index])));
    for (let a = 0; a < live.length; a++) {
      const ra = rasterAt(a, offsets[a]);
      const sa = shifts[a];
      for (let b = a + 1; b < live.length; b++) {
        const rb = rasterAt(b, offsets[b]);
        const sb = shifts[b];
        if (ra.x1 + sa.gx < rb.x0 + sb.gx || rb.x1 + sb.gx < ra.x0 + sa.gx || ra.y1 + sa.gy < rb.y0 + sb.gy || rb.y1 + sb.gy < ra.y0 + sa.gy) continue;
        if (skip(live[a].item, live[b].item)) continue;
        const dx = sb.gx - sa.gx;
        const dy = sb.gy - sa.gy;
        const dz = sb.dz - sa.dz;
        const id = `${ra.uid}|${rb.uid}|${dx},${dy},${dz.toFixed(3)}`;
        let hit = memo.get(id);
        if (hit === undefined) {
          hit = measure(ra, rb, dx, dy, dz, cam, step, tolerance, least);
          memo.set(id, hit);
        }
        if (!hit) continue;
        pairs.push({ state, a: live[a].index, b: live[b].index, front: hit.map((side) => ({ ...side, at: side.at && [side.at[0] + sa.gx * step, side.at[1] + sa.gy * step] })) });
      }
    }
  }
  return { pairs, least, states, step, V: cam.V };
}

const liftOf = (item, V) => dot3(item.move ?? [0, 0, 0], V);

export const keyAt = (item, state, V) => item.key + liftOf(item, V) * shareOf(item, state);

export function orderKeys(items, overlaps, { gap = 0.02, rounds = 0 } = {}) {
  const { pairs, least, V } = overlaps;
  const start = items.map((item) => item.key);
  const strongest = new Map();
  const cycles = [];
  for (const pair of pairs) {
    const aFront = pair.front[0].area >= least;
    const bFront = pair.front[1].area >= least;
    if (aFront && bFront) {
      cycles.push(pair);
      continue;
    }
    const [front, back] = aFront ? [pair.a, pair.b] : [pair.b, pair.a];
    const weight = pair.front[aFront ? 0 : 1].area;
    const need = gap + shareOf(items[back], pair.state) * liftOf(items[back], V) - shareOf(items[front], pair.state) * liftOf(items[front], V);
    const id = `${front}|${back}`;
    const prior = strongest.get(id);
    if (!prior) strongest.set(id, { front, back, weight, pair, need });
    else {
      prior.weight = Math.max(prior.weight, weight);
      if (need > prior.need) (prior.need = need), (prior.pair = pair);
    }
  }
  const rules = [...strongest.values()];
  const limit = rounds || Math.min(items.length + 1, 400);
  const dropped = [];
  let keys = start.slice();
  let active = rules.slice();
  for (let attempt = 0; attempt < 400; attempt++) {
    keys = start.slice();
    const by = new Array(items.length).fill(null);
    let changed = true;
    let round = 0;
    let last = -1;
    for (; changed && round < limit; round++) {
      changed = false;
      for (const rule of active) {
        if (keys[rule.front] - keys[rule.back] >= rule.need - 1e-9) continue;
        keys[rule.front] = keys[rule.back] + rule.need;
        by[rule.front] = rule;
        last = rule.front;
        changed = true;
      }
    }
    if (!changed) break;
    const seen = new Map();
    let node = last;
    let step = 0;
    while (node !== -1 && by[node] && !seen.has(node) && step < items.length * 2) {
      seen.set(node, step++);
      node = by[node].back;
    }
    const loop = [];
    if (node !== -1 && seen.has(node)) {
      let walk = node;
      do {
        loop.push(by[walk]);
        walk = by[walk].back;
      } while (walk !== node && loop.length <= items.length);
    }
    const victim = (loop.length ? loop : active.filter((rule) => keys[rule.front] - keys[rule.back] < rule.need - 1e-9)).reduce((low, rule) => (rule.weight < low.weight ? rule : low));
    dropped.push(victim);
    active = active.filter((rule) => rule !== victim);
  }
  return { keys, rules: rules.length, cycles, dropped };
}

export function depthOrder(items, P, options = {}) {
  const overlaps = options.overlaps ?? overlapsOf(items, P, options);
  const { pairs, least, states, V } = overlaps;
  const allow = options.allow ?? [];
  const dropped = new Set((options.solved?.dropped ?? []).map((rule) => `${rule.front}|${rule.back}`));
  const missing = items.filter((item) => !item.shapes || !item.shapes.length).map((item) => item.name);
  const ranks = new Map();
  for (const state of states) {
    const order = items.map((item, index) => ({ index, key: keyAt(item, state, V) })).sort((x, y) => x.key - y.key || x.index - y.index);
    const rank = new Int32Array(items.length);
    order.forEach(({ index }, position) => (rank[index] = position));
    ranks.set(state, rank);
  }
  const rest = options.rest ?? states.filter((state) => state === 0 || state === 1);
  const problems = [];
  const passing = [];
  for (const pair of pairs) {
    const rank = ranks.get(pair.state);
    const aFirst = rank[pair.a] < rank[pair.b];
    const wrong = pair.front[aFirst ? 0 : 1];
    const right = pair.front[aFirst ? 1 : 0];
    if (wrong.area < least) continue;
    const front = items[aFirst ? pair.a : pair.b];
    const back = items[aFirst ? pair.b : pair.a];
    const excused = allow.find(([x, y]) => (matches([x], front.name) && matches([y], back.name)) || (matches([y], front.name) && matches([x], back.name)));
    const entry = {
      state: pair.state,
      label: labelOf(pair.state),
      front: front.name,
      back: back.name,
      frontKey: keyAt(front, pair.state, V),
      backKey: keyAt(back, pair.state, V),
      cells: wrong.all,
      area: wrong.area,
      against: right.area,
      cycle: right.area >= least,
      depth: wrong.depth,
      at: wrong.at,
    };
    const front_ = aFirst ? pair.a : pair.b;
    const back_ = aFirst ? pair.b : pair.a;
    entry.unorderable = !rest.includes(pair.state) && dropped.has(`${front_}|${back_}`);
    const replayed = typeof pair.state !== "number";
    (!excused && (rest.includes(pair.state) || replayed || (!entry.cycle && !entry.unorderable)) ? problems : passing).push(entry);
  }
  return { problems, passing, missing };
}

function samplesOf(shape, spacing) {
  const dense = resample(shape.points, spacing);
  const total = pathLength(shape.points) || 1;
  const lengths = [0];
  for (let index = 1; index < shape.points.length; index++) lengths.push(lengths[index - 1] + len3(sub3(shape.points[index], shape.points[index - 1])));
  const radiusAt = (at) => {
    let index = 1;
    while (index < lengths.length - 1 && lengths[index] < at) index++;
    const span = lengths[index] - lengths[index - 1] || 1;
    const t = Math.max(0, Math.min(1, (at - lengths[index - 1]) / span));
    return shape.radii[index - 1] + (shape.radii[index] - shape.radii[index - 1]) * t;
  };
  const step = total / Math.max(1, dense.length - 1);
  return dense.map((p, index) => {
    const t = unit3(sub3(dense[Math.min(dense.length - 1, index + 1)], dense[Math.max(0, index - 1)]));
    return { p, t, along: index * step, edge: Math.min(index * step, total - index * step), r: radiusAt(index * step) };
  });
}

const overlaps = (a, b, pad) => a.min.every((v, i) => v - pad <= b.max[i] && b.min[i] - pad <= a.max[i]);

function centreline(shape, p) {
  let best = { d: BIG, r: 0 };
  for (let index = 0; index < shape.points.length - 1; index++) {
    const { d, t } = segmentPoint(p, shape.points[index], shape.points[index + 1]);
    if (d < best.d) best = { d, r: shape.radii[index] + (shape.radii[index + 1] - shape.radii[index]) * t };
  }
  return best;
}

const matches = (list, name) =>
  (list ?? []).some((entry) => {
    if (typeof entry === "function") return entry(name);
    if (entry.endsWith("*")) return name.startsWith(entry.slice(0, -1));
    return name === entry || name.startsWith(entry + ".") || name.startsWith(entry + ":") || name.startsWith(entry + "#");
  });

const sameOffset = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) < 1e-6;

const placed = (shape, state) => (state === undefined || !shape.move ? shape : moved(shape, offsetOf(shape, state)));

const offsetAt = (shape, state) => (state === undefined || !shape.move ? [0, 0, 0] : offsetOf(shape, state));

function tipDistance(other, p) {
  if (other.kind === "tube") {
    const { d, r } = centreline(other, p);
    return d - r;
  }
  return sdf(other, p);
}

function rimOf(tube, index) {
  const tip = tube.tips[index];
  const axis = tube.tipAxes ? tube.tipAxes[index] : unit3(index ? sub3(tube.points[tube.points.length - 1], tube.points[tube.points.length - 2]) : sub3(tube.points[1], tube.points[0]));
  const radius = tube.tipRims ? tube.tipRims[index] : tube.radii[index ? tube.radii.length - 1 : 0];
  const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
  const u = unit3([helper[1] * axis[2] - helper[2] * axis[1], helper[2] * axis[0] - helper[0] * axis[2], helper[0] * axis[1] - helper[1] * axis[0]]);
  const v = [axis[1] * u[2] - axis[2] * u[1], axis[2] * u[0] - axis[0] * u[2], axis[0] * u[1] - axis[1] * u[0]];
  return Array.from({ length: 12 }, (_, k) => add3(tip, add3(mul3(u, radius * Math.cos((k * Math.PI) / 6)), mul3(v, radius * Math.sin((k * Math.PI) / 6)))));
}

export function mountsOf(tubes, solids, { tolerance = 0.15 } = {}) {
  const byName = new Map(tubes.map((tube) => [tube.name, tube]));
  const out = new Map();
  for (const tube of tubes) {
    const found = [null, null];
    (tube.tips ?? []).forEach((tip, index) => {
      if (!tip) return;
      const rim = rimOf(tube, index);
      let best = null;
      const consider = (other) => {
        if (other === tube || (other.owner && other.owner === tube.owner) || other.name === tube.owner || other.anchor === false) return;
        const { min, max } = boundsOf(other);
        if (!overlaps({ min: tip, max: tip }, { min, max }, 3 + (tube.tipRims ? tube.tipRims[index] : 0))) return;
        const ds = rim.map((p) => tipDistance(other, p));
        const score = (Math.abs(tipDistance(other, tip)) + ds.reduce((sum, d) => sum + Math.abs(d), 0) / ds.length) / 2;
        if (!best || score < best.score) best = { shape: other, score, d: tipDistance(other, tip), high: Math.max(...ds), low: Math.min(...ds) };
      };
      solids.forEach(consider);
      tubes.forEach(consider);
      if (best && byName.get(best.shape.owner) && best.shape.kind !== "tube") best.host = byName.get(best.shape.owner);
      found[index] = best && { ...best, tip, landed: best.high <= tolerance && best.low >= -tolerance };
    });
    out.set(tube, found);
  }
  return out;
}

function ringAround(sample, r) {
  const axis = sample.t;
  const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
  const u = unit3([helper[1] * axis[2] - helper[2] * axis[1], helper[2] * axis[0] - helper[0] * axis[2], helper[0] * axis[1] - helper[1] * axis[0]]);
  const v = [axis[1] * u[2] - axis[2] * u[1], axis[2] * u[0] - axis[0] * u[2], axis[0] * u[1] - axis[1] * u[0]];
  return Array.from({ length: 12 }, (_, k) => add3(sample.p, add3(mul3(u, r * Math.cos((k * Math.PI) / 6)), mul3(v, r * Math.sin((k * Math.PI) / 6)))));
}

export function clearances(tubes, solids, { ratio = 0.5, least = 0.2, spacing = 0.5, touching = -0.05, through = -0.1, state, only, mounts = mountsOf(tubes, solids) } = {}) {
  const gapOf = (r) => Math.max(least, ratio * r);
  const problems = new Map();
  const note = (tube, other, kind, clearance, need, sample) => {
    if (only && kind !== only) return;
    const id = `${tube.name}|${other.name}`;
    const prior = problems.get(id);
    if (prior && prior.clearance <= clearance) {
      prior.from = Math.min(prior.from, sample.along);
      prior.to = Math.max(prior.to, sample.along);
      return;
    }
    problems.set(id, { tube: tube.name, other: other.name, kind, clearance, need, at: sample.p, from: prior ? Math.min(prior.from, sample.along) : sample.along, to: prior ? Math.max(prior.to, sample.along) : sample.along, length: pathLength(tube.points), state: state === undefined ? "" : labelOf(state) });
  };
  const poseOf = new Map([...tubes, ...solids].map((shape) => [shape, placed(shape, state)]));
  const offsets = new Map([...tubes, ...solids].map((shape) => [shape, offsetAt(shape, state)]));
  const together = (a, b) => len3(sub3(offsets.get(a), offsets.get(b))) < 0.05;
  const runsBy = new Map();
  for (const tube of tubes) runsBy.set(tube.owner, [...(runsBy.get(tube.owner) ?? []), tube]);
  const tipsOf = (owner) => (runsBy.get(owner) ?? []).flatMap((run) => (mounts.get(run) ?? []).filter(Boolean).map((mount) => ({ run, mount })));
  const seatedOn = (shape, owner) => shape && shape.cut && [shape.cut].flat().some((cut) => cut.owner === owner);
  const zone = (r) => r + gapOf(r) + 1;
  const ownerOf = (other) => other.owner ?? other.name;
  const related = (tube, other, sample) => {
    if (seatedOn(other, tube.owner)) return true;
    const tips = tipsOf(tube.owner);
    const near = (entry) => len3(sub3(sample.p, add3(entry.mount.tip, offsets.get(entry.run)))) <= zone(sample.r) + (entry.run.tipRims ? Math.max(...entry.run.tipRims) : 0);
    for (const entry of tips) {
      if (!near(entry) || !together(tube, entry.mount.shape)) continue;
      const mount = entry.mount.shape;
      if (mount === other || (entry.mount.host && (entry.mount.host === other || other.owner === entry.mount.host.owner))) return true;
      if (tipsOf(ownerOf(other)).some((theirs) => theirs.mount.shape === mount)) return true;
    }
    for (const entry of tipsOf(ownerOf(other))) {
      const mount = entry.mount.shape;
      const lands = mount.owner === tube.owner || seatedOn(mount, tube.owner);
      if (!lands || !together(entry.run, tube)) continue;
      if (len3(sub3(sample.p, add3(entry.mount.tip, offsets.get(entry.run)))) <= zone(sample.r) + (entry.run.tipRims ? Math.max(...entry.run.tipRims) : 0) + sample.r) return true;
    }
    return false;
  };
  const nearTip = (tube, sample) => {
    const at = (tube.offset ?? 0) + sample.along;
    const total = tube.total ?? pathLength(tube.points);
    const tips = mounts.get(tube) ?? [null, null];
    return (tips[0] && at <= zone(sample.r)) || (tips[1] && total - at <= zone(sample.r));
  };
  const clearanceTo = (other, sample) => {
    if (sample.edge >= sample.r) return sdf(other, sample.p) - sample.r;
    return Math.min(...ringAround(sample, sample.r).map((q) => sdf(other, q)));
  };
  for (const tube of tubes) {
    const posed = poseOf.get(tube);
    const samples = samplesOf(posed, spacing);
    const tb = boundsOf(posed);
    for (const shape of solids) {
      if (shape.owner && shape.owner === tube.owner) continue;
      if (matches(tube.touch, shape.name) || matches(shape.touch, tube.name)) continue;
      const other = poseOf.get(shape);
      const ob = boundsOf(other);
      if (!overlaps(tb, ob, 4)) continue;
      for (const sample of samples) {
        if (!overlaps({ min: sample.p, max: sample.p }, ob, sample.r + 3)) continue;
        const gap = gapOf(sample.r);
        const clearance = clearanceTo(other, sample);
        if (clearance >= gap) continue;
        if (related(tube, shape, sample)) continue;
        if (nearTip(tube, sample) && clearance >= through) continue;
        note(tube, shape, clearance < 0 ? "through" : "close", clearance, gap, sample);
      }
    }
  }
  for (let a = 0; a < tubes.length; a++) {
    const ta = poseOf.get(tubes[a]);
    const samples = samplesOf(ta, spacing);
    for (let b = 0; b < tubes.length; b++) {
      if (a === b) continue;
      const tb = poseOf.get(tubes[b]);
      if (tubes[a].owner && tubes[a].owner === tubes[b].owner && tubes[a].joined) continue;
      if (matches(tubes[a].touch, tubes[b].name) || matches(tubes[b].touch, tubes[a].name)) continue;
      if (!overlaps(boundsOf(ta), boundsOf(tb), 3)) continue;
      const bundled = tubes[a].bundle && tubes[a].bundle === tubes[b].bundle;
      for (const sample of samples) {
        if (!overlaps({ min: sample.p, max: sample.p }, boundsOf(tb), sample.r + 3)) continue;
        const clearance = sample.edge >= sample.r ? sdf(tb, sample.p) - sample.r : Math.min(...ringAround(sample, sample.r).map((q) => sdf(tb, q)));
        const r = centreline(tb, sample.p).r;
        const gap = bundled ? touching : gapOf(Math.min(sample.r, r));
        if (clearance >= gap) continue;
        if (related(tubes[a], tubes[b], sample)) continue;
        if (nearTip(tubes[a], sample) && clearance >= through) continue;
        if (a > b && problems.has(`${tubes[b].name}|${tubes[a].name}`)) continue;
        note(tubes[a], tubes[b], clearance < 0 ? "through" : "close", clearance, gap, sample);
      }
    }
  }
  const seen = new Set();
  return [...problems.values()]
    .sort((x, y) => x.clearance - y.clearance)
    .filter((c) => {
      const id = [c.tube, c.other].sort().join("|");
      if (seen.has(id)) return false;
      seen.add(id);
      return true;
    });
}

export function terminals(tubes, solids, { tolerance = 0.15, mounts = mountsOf(tubes, solids, { tolerance }) } = {}) {
  const out = [];
  for (const tube of tubes) {
    (mounts.get(tube) ?? []).forEach((mount, index) => {
      if (!tube.tips || !tube.tips[index] || (mount && mount.landed)) return;
      out.push({ tube: tube.name, end: index ? "end" : "start", at: tube.tips[index], d: mount ? mount.d : Infinity, high: mount ? mount.high : Infinity, low: mount ? mount.low : -Infinity, nearest: mount ? mount.shape.name : "nothing" });
    });
  }
  return out;
}

export function looseEnds(tubes, solids, { state = 1, tolerance = 0.15 } = {}) {
  const out = [];
  for (const tube of tubes) {
    const offset = offsetAt(tube, state);
    (tube.tips ?? []).forEach((tip, index) => {
      if (!tip) return;
      const hit = solids.some((shape) => {
        if ((shape.owner && shape.owner === tube.owner) || shape.name === tube.owner) return false;
        if (!sameOffset(offsetAt(shape, state), offset)) return false;
        const { min, max } = boundsOf(shape);
        return overlaps({ min: tip, max: tip }, { min, max }, 1) && sdf(shape, tip) <= tolerance;
      });
      if (!hit) out.push({ tube: tube.name, end: index ? "end" : "start", at: add3(tip, offset), state: labelOf(state), free: Boolean(tube.free && tube.free[index]) });
    });
  }
  return out;
}

function surfaceOf(shape, spacing = 0.6) {
  const points = [];
  if (shape.kind === "body") {
    const { F, poly } = shape;
    const u = F.u ?? unit3(Math.abs(F.a[2]) > 0.9 ? [1, 0, 0] : [-F.a[1], F.a[0], 0]);
    const v = F.v ?? [F.a[1] * u[2] - F.a[2] * u[1], F.a[2] * u[0] - F.a[0] * u[2], F.a[0] * u[1] - F.a[1] * u[0]];
    for (let i = 0; i < poly.length - 1; i++) {
      const [s0, r0] = poly[i];
      const [s1, r1] = poly[i + 1];
      const steps = Math.max(1, Math.ceil(Math.hypot(s1 - s0, r1 - r0) / spacing));
      for (let j = 0; j <= steps; j++) {
        const s = s0 + ((s1 - s0) * j) / steps;
        const r = r0 + ((r1 - r0) * j) / steps;
        const around = Math.max(1, Math.min(160, Math.ceil((2 * Math.PI * r) / spacing)));
        for (let k = 0; k < around; k++) {
          const t = (k / around) * 2 * Math.PI;
          points.push(add3(add3(F.o, mul3(F.a, s)), add3(mul3(u, r * Math.cos(t)), mul3(v, r * Math.sin(t)))));
        }
      }
    }
  } else if (shape.kind === "box") {
    for (let axis = 0; axis < 3; axis++)
      for (const sign of [-1, 1]) {
        const o1 = (axis + 1) % 3;
        const o2 = (axis + 2) % 3;
        const n1 = Math.max(1, Math.ceil((2 * shape.half[o1]) / spacing));
        const n2 = Math.max(1, Math.ceil((2 * shape.half[o2]) / spacing));
        for (let i = 0; i <= n1; i++)
          for (let j = 0; j <= n2; j++) {
            const c = [0, 0, 0];
            c[axis] = sign * shape.half[axis];
            c[o1] = -shape.half[o1] + (2 * shape.half[o1] * i) / n1;
            c[o2] = -shape.half[o2] + (2 * shape.half[o2] * j) / n2;
            points.push(add3(shape.o, add3(add3(mul3(shape.axes[0], c[0]), mul3(shape.axes[1], c[1])), mul3(shape.axes[2], c[2]))));
          }
      }
  } else if (shape.kind === "prism") {
    const { F, poly, s0, s1 } = shape;
    const at = (s, x, y) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
    const rows = Math.max(1, Math.ceil((s1 - s0) / spacing));
    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
      const [x0, y0] = poly[j];
      const [x1, y1] = poly[i];
      const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / spacing));
      for (let k = 0; k < steps; k++)
        for (let row = 0; row <= rows; row++) points.push(at(s0 + ((s1 - s0) * row) / rows, x0 + ((x1 - x0) * k) / steps, y0 + ((y1 - y0) * k) / steps));
    }
    const xs = poly.map(([x]) => x);
    const ys = poly.map(([, y]) => y);
    for (let x = Math.min(...xs); x <= Math.max(...xs); x += spacing)
      for (let y = Math.min(...ys); y <= Math.max(...ys); y += spacing) if (polygonDistance(poly, x, y) < 0) points.push(at(s0, x, y), at(s1, x, y));
  } else if (shape.kind === "ball") {
    const count = Math.max(24, Math.min(600, Math.ceil((4 * Math.PI * shape.r * shape.r) / (spacing * spacing))));
    for (let i = 0; i < count; i++) {
      const z = 1 - (2 * (i + 0.5)) / count;
      const ring = Math.sqrt(1 - z * z);
      const t = i * 2.399963;
      points.push(add3(shape.o, mul3([ring * Math.cos(t), ring * Math.sin(t), z], shape.r)));
    }
    if (shape.flats) {
      const kept = points.filter((p) => shape.flats.every(([nx, ny, nz, cut]) => nx * (p[0] - shape.o[0]) + ny * (p[1] - shape.o[1]) + nz * (p[2] - shape.o[2]) <= cut + 1e-9));
      points.length = 0;
      points.push(...kept);
      for (const [nx, ny, nz, cut] of shape.flats) {
        const n = [nx, ny, nz];
        const helper = Math.abs(nz) > 0.9 ? [1, 0, 0] : [0, 0, 1];
        const u = unit3([helper[1] * nz - helper[2] * ny, helper[2] * nx - helper[0] * nz, helper[0] * ny - helper[1] * nx]);
        const v = [ny * u[2] - nz * u[1], nz * u[0] - nx * u[2], nx * u[1] - ny * u[0]];
        const rho = Math.sqrt(Math.max(0, shape.r * shape.r - cut * cut));
        const centre = add3(shape.o, mul3(n, cut));
        for (let x = -rho; x <= rho; x += spacing)
          for (let y = -rho; y <= rho; y += spacing) {
            if (x * x + y * y > rho * rho) continue;
            const p = add3(centre, add3(mul3(u, x), mul3(v, y)));
            if (shape.flats.every(([mx, my, mz, other]) => mx * (p[0] - shape.o[0]) + my * (p[1] - shape.o[1]) + mz * (p[2] - shape.o[2]) <= other + 1e-6)) points.push(p);
          }
      }
    }
  } else {
    for (const { p, r } of samplesOf(shape, spacing)) for (let k = 0; k < 8; k++) points.push(add3(p, mul3([Math.cos((k * Math.PI) / 4), Math.sin((k * Math.PI) / 4), 0], r)));
  }
  return shape.cut ? points.filter((p) => cutDistance(shape, p) >= -1e-3) : points;
}

const seatedOn = (shape, host) => Boolean(shape.seated && shape.cut && [shape.cut].flat().some((cut) => cut === host || (cut.name && cut.name === host.name)));

export const grounded = (shape) => Boolean(shape.ground) || (shape.name ?? "").startsWith("stand.");

export function supports(solids, tubes, { ground = grounded, touch = 0.15, sunk = 0.5 } = {}) {
  const nodes = [...solids, ...tubes.filter((tube) => !tube.limp)];
  const surfaces = nodes.map((shape) => surfaceOf(shape));
  const links = nodes.map(() => []);
  const deep = [];
  for (let a = 0; a < nodes.length; a++) {
    const ba = boundsOf(nodes[a]);
    for (let b = a + 1; b < nodes.length; b++) {
      const bb = boundsOf(nodes[b]);
      if (!overlaps(ba, bb, touch + 0.05)) continue;
      const same = (nodes[a].owner && (nodes[a].owner === nodes[b].owner || nodes[a].owner === nodes[b].name)) || (nodes[b].owner && nodes[b].owner === nodes[a].name);
      let gap = BIG;
      for (const p of surfaces[a]) gap = Math.min(gap, sdf(nodes[b], p));
      for (const p of surfaces[b]) gap = Math.min(gap, sdf(nodes[a], p));
      if (gap <= touch) links[a].push(b), links[b].push(a);
      const solidPair = nodes[a].kind !== "tube" && nodes[b].kind !== "tube";
      if (solidPair && !same && gap < -sunk && !seatedOn(nodes[a], nodes[b]) && !seatedOn(nodes[b], nodes[a])) deep.push({ a: nodes[a].name, b: nodes[b].name, depth: -gap });
    }
  }
  const held = new Uint8Array(nodes.length);
  const queue = [];
  nodes.forEach((shape, index) => ground(shape) && ((held[index] = 1), queue.push(index)));
  while (queue.length) {
    const index = queue.pop();
    for (const next of links[index]) if (!held[next]) (held[next] = 1), queue.push(next);
  }
  const loose = nodes.filter((_, index) => !held[index] && nodes[index].kind !== "tube").map((shape) => shape.name);
  const hanging = nodes.filter((_, index) => !held[index] && nodes[index].kind === "tube").map((shape) => shape.name);
  return { loose, hanging, sunk: deep.sort((x, y) => y.depth - x.depth) };
}

export function folds(routes, P, { fold = 140, turn = 110, pinch = 2, items = null } = {}) {
  const cam = cameraBasis(P);
  const entries = items ? entriesOf(items, cam) : null;
  const shown = (name, p) => !entries || frontAt(entries, cam, cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k) === name;
  const flat = (p) => [dot3(p, cam.R) * cam.k, dot3(p, cam.D) * cam.k];
  const angle = (a, b) => (Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1]) / (Math.hypot(a[0], a[1]) * Math.hypot(b[0], b[1]) || 1)))) * 180) / Math.PI;
  const out = [];
  for (const { name, points, r } of routes) {
    const step = Math.max(0.25, r * 0.5);
    const dense = resample(points, step);
    const screen = dense.map(flat);
    const reach = Math.max(2, Math.round((4 * r) / step));
    const width = r * cam.k;
    let worst = null;
    for (const span of [1.5, 3, 6]) {
      const near = Math.max(1, Math.round((span * r) / step));
      for (let i = near; i < dense.length - near; i++) {
        const p0 = screen[i - near];
        const p1 = screen[i];
        const p2 = screen[i + near];
        const a = [p1[0] - p0[0], p1[1] - p0[1]];
        const b = [p2[0] - p1[0], p2[1] - p1[1]];
        const c = [p2[0] - p0[0], p2[1] - p0[1]];
        const la = Math.hypot(a[0], a[1]);
        const lb = Math.hypot(b[0], b[1]);
        if (la < width * 0.3 || lb < width * 0.3) continue;
        const bend = angle(a, b);
        const area = Math.abs(a[0] * b[1] - a[1] * b[0]) / 2;
        const radius = area > 1e-9 ? (la * lb * Math.hypot(c[0], c[1])) / (4 * area) : Infinity;
        if (bend > 90 && radius < pinch * width && (!worst || radius < worst.radius) && shown(name, dense[i])) worst = { name, kind: "pinch", flatTurn: bend, radius, width, at: dense[i], index: i };
      }
    }
    if (worst) {
      let toStart = 0;
      for (let i = 1; i <= worst.index; i++) toStart += Math.hypot(screen[i][0] - screen[i - 1][0], screen[i][1] - screen[i - 1][1]);
      let toEnd = 0;
      for (let i = worst.index + 1; i < screen.length; i++) toEnd += Math.hypot(screen[i][0] - screen[i - 1][0], screen[i][1] - screen[i - 1][1]);
      if (Math.min(toStart, toEnd) > 3 * width) out.push(worst);
    }
    for (let i = reach; i < dense.length - reach; i++) {
      const a = [screen[i][0] - screen[i - reach][0], screen[i][1] - screen[i - reach][1]];
      const b = [screen[i + reach][0] - screen[i][0], screen[i + reach][1] - screen[i][1]];
      if (Math.hypot(a[0], a[1]) < 1e-6 || Math.hypot(b[0], b[1]) < 1e-6) continue;
      const flatTurn = angle(a, b);
      const A3 = sub3(dense[i], dense[i - reach]);
      const B3 = sub3(dense[i + reach], dense[i]);
      const realTurn = (Math.acos(Math.max(-1, Math.min(1, dot3(A3, B3) / (len3(A3) * len3(B3) || 1)))) * 180) / Math.PI;
      if (flatTurn > fold && realTurn < turn && shown(name, dense[i])) {
        out.push({ name, kind: "fold", flatTurn, realTurn, at: dense[i] });
        i += 2 * reach;
      }
    }
  }
  return out;
}

export function linksOf(tubes, mounts) {
  const links = new Map();
  const link = (a, b) => {
    if (!a || !b || a === b) return;
    if (!links.has(a)) links.set(a, new Set());
    if (!links.has(b)) links.set(b, new Set());
    links.get(a).add(b);
    links.get(b).add(a);
  };
  const ownerOf = (shape) => shape.owner ?? shape.name;
  const seats = new Map();
  for (const tube of tubes)
    for (const mount of mounts.get(tube) ?? []) {
      if (!mount) continue;
      const host = ownerOf(mount.shape);
      link(tube.owner, host);
      if (!seats.has(host)) seats.set(host, []);
      seats.get(host).push(tube.owner);
      if (mount.shape.cut) for (const cut of [mount.shape.cut].flat()) link(tube.owner, ownerOf(cut));
    }
  for (const owners of seats.values()) for (const a of owners) for (const b of owners) link(a, b);
  return links;
}

const entriesOf = (items, cam) => items.flatMap((item) => (item.shapes ?? []).map((shape) => ({ shape, box: screenBox(shape, cam, [0, 0, 0]).map((v, i) => v + (i < 2 ? -1 : 1)), owner: item.route ?? shape.owner ?? item.name, bundle: shape.bundle ?? null, item: item.name })));

function frontEntry(entries, cam, x, y) {
  const c = add3(mul3(cam.R, (x - cam.origin[0]) / cam.k), mul3(cam.D, (y - cam.origin[1]) / cam.k));
  let best = -BIG;
  let who = null;
  for (const entry of entries) {
    const { shape, box } = entry;
    if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) continue;
    const t = rayDepth(shape, c, cam.V, 0.02, null);
    if (t !== null && t > best) (best = t), (who = entry);
  }
  return who;
}

function frontAt(entries, cam, x, y) {
  const entry = frontEntry(entries, cam, x, y);
  return entry ? entry.owner : null;
}

const belongs = (entry, name, bundle) => Boolean(entry) && (entry.owner === name || entry.item === name || (bundle && entry.bundle === bundle));

export function slenderOf(solids, { ratio = 4, least = 0.3 } = {}) {
  const out = [];
  const axisLine = (centre, axis, half, r, shape) => {
    out.push({ name: shape.owner ?? shape.name, solid: shape.name, owners: new Set([shape.name, shape.owner].filter(Boolean)), points: [add3(centre, mul3(axis, -half)), add3(centre, mul3(axis, half))], r, move: shape.move, ride: shape.ride, piece: shape.piece, rod: true });
  };
  for (const shape of solids) {
    if (!shape || shape.kind === "tube" || shape.kind === "ball" || shape.fitting) continue;
    if (shape.kind === "body") {
      const s = shape.poly.map(([value]) => value);
      const s0 = Math.min(...s);
      const s1 = Math.max(...s);
      const r = Math.max(...shape.poly.map(([, value]) => value));
      if (r >= least && s1 - s0 >= ratio * r) axisLine(add3(shape.F.o, mul3(shape.F.a, (s0 + s1) / 2)), shape.F.a, (s1 - s0) / 2, r, shape);
      continue;
    }
    let axes;
    let half;
    let centre;
    if (shape.kind === "prism") {
      const xs = shape.poly.map(([x]) => x);
      const ys = shape.poly.map(([, y]) => y);
      const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
      axes = [shape.F.u, shape.F.v, shape.F.a];
      half = [(x1 - x0) / 2, (y1 - y0) / 2, (shape.s1 - shape.s0) / 2];
      centre = add3(shape.F.o, add3(add3(mul3(shape.F.u, (x0 + x1) / 2), mul3(shape.F.v, (y0 + y1) / 2)), mul3(shape.F.a, (shape.s0 + shape.s1) / 2)));
    } else if (shape.kind === "box") {
      axes = shape.axes;
      half = shape.half;
      centre = shape.o;
    } else continue;
    const order = [0, 1, 2].sort((a, b) => half[b] - half[a]);
    const r = half[order[1]];
    if (r >= least && half[order[0]] >= ratio * r) axisLine(centre, axes[order[0]], half[order[0]], r, shape);
  }
  return out;
}

export function crowding(routes, P, { gap = 1, bend = 30, links = new Map(), items = null, allow = [], state, clear = 2, rods = [] } = {}) {
  const cam = cameraBasis(P);
  const shift = (thing) => (state === undefined ? [0, 0, 0] : offsetOf(thing, state));
  const entries = items ? entriesOf(items.map((item) => ({ ...item, shapes: (item.shapes ?? []).map((shape) => moved(shape, shift(item))) })), cam) : null;
  const bundles = new Map(routes.map((route) => [route.name, route.bundle]));
  const shown = (owner, x, y, rod) => {
    if (!entries) return true;
    const entry = frontEntry(entries, cam, x, y);
    if (rod) return Boolean(entry) && (entry.shape.name === rod.solid || (!entry.shape.name && entry.item === rod.solid));
    return belongs(entry, owner, bundles.get(owner));
  };
  const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
  const lines = [...routes, ...rods].map((route) => {
    const step = Math.max(0.25, route.r * 0.5);
    const offset = shift(route);
    const dense = resample(route.points.map((p) => add3(p, offset)), step);
    return { ...route, step, dense, screen: dense.map(flat), width: route.r * cam.k };
  });
  const marksOf = (line) => {
    const out = [];
    const reach = Math.max(2, Math.round((2 * line.r) / line.step));
    const last = line.dense.length - 1;
    for (let i = 0; i <= last; i++) {
      if (i <= reach || i >= last - reach) {
        out.push({ i, where: i <= reach ? "start" : "end" });
        continue;
      }
      const A = sub3(line.dense[i], line.dense[i - reach]);
      const B = sub3(line.dense[i + reach], line.dense[i]);
      const turn = (Math.acos(Math.max(-1, Math.min(1, dot3(A, B) / (len3(A) * len3(B) || 1)))) * 180) / Math.PI;
      if (turn > bend) out.push({ i, where: "bend" });
    }
    return out;
  };
  const found = new Map();
  for (const a of lines) {
    if (a.rod) continue;
    const marks = marksOf(a);
    for (const b of lines) {
      if (a === b || (a.bundle && a.bundle === b.bundle) || links.get(a.name)?.has(b.name)) continue;
      if (b.rod && [...b.owners].some((name) => name === a.name || links.get(a.name)?.has(name))) continue;
      if (allow.some(([x, y]) => [b.name, b.solid].filter(Boolean).some((name) => (matches([x], a.name) && matches([y], name)) || (matches([y], a.name) && matches([x], name))))) continue;
      for (const { i, where } of marks) {
        const [x, y] = a.screen[i];
        let best = { d: BIG, j: -1 };
        for (let j = 0; j < b.screen.length; j++) {
          const d = Math.hypot(b.screen[j][0] - x, b.screen[j][1] - y);
          if (d < best.d) best = { d, j };
        }
        const touch = best.d - a.width - b.width;
        if (touch >= gap) continue;
        if (where !== "bend" && touch <= -2 * Math.min(a.width, b.width)) {
          const tip = a.screen[where === "start" ? 0 : a.screen.length - 1];
          let clearTip = BIG;
          for (const q of b.screen) clearTip = Math.min(clearTip, Math.hypot(q[0] - tip[0], q[1] - tip[1]));
          const reachB = Math.max(2, Math.round((2 * b.r) / b.step));
          const j0 = Math.max(0, best.j - reachB);
          const j1 = Math.min(b.dense.length - 1, best.j + reachB);
          const straight = j1 - j0 >= 2 && dot3(unit3(sub3(b.dense[best.j], b.dense[j0])), unit3(sub3(b.dense[j1], b.dense[best.j]))) > Math.cos((bend * Math.PI) / 180);
          if (straight && clearTip > b.width + a.width + 1) continue;
        }
        const [bx, by] = b.screen[best.j];
        const u = [(bx - x) / (best.d || 1), (by - y) / (best.d || 1)];
        const inA = Math.max(0.2, a.width - 0.35);
        const inB = Math.max(0.2, b.width - 0.35);
        if (!shown(a.name, x + u[0] * inA, y + u[1] * inA)) continue;
        if (b.rod) {
          if (!shown(b.name, bx - u[0] * inB, by - u[1] * inB, b) && !shown(b.name, bx + u[0] * inB, by + u[1] * inB, b)) continue;
        } else if (!shown(b.name, bx - u[0] * inB, by - u[1] * inB)) continue;
        const depth = Math.abs(dot3(sub3(a.dense[i], b.dense[best.j]), cam.V));
        const need = 2 * (a.r + b.r);
        const id = [a.name, b.name].sort().join("|");
        const prior = found.get(id);
        if (!prior || Math.abs(touch) < Math.abs(prior.gap)) found.set(id, { a: a.name, b: b.rod ? `${b.solid} (rod)` : b.name, where, gap: touch, depth, need, screen: [x, y], at: a.dense[i], state: state === undefined ? "" : labelOf(state) });
      }
    }
  }
  if (entries)
    for (const fitting of entries.filter((entry) => entry.shape.fitting && entry.shape.kind === "body")) {
      const { shape } = fitting;
      const reach = Math.max(...shape.poly.map(([, r]) => r));
      const [x, y] = flat(shape.F.o);
      for (const b of lines) {
        if (b.rod) continue;
        if ((fitting.bundle && b.bundle === fitting.bundle) || b.name === fitting.owner || links.get(fitting.owner)?.has(b.name)) continue;
        if (allow.some(([p, q]) => (matches([p], fitting.item) && matches([q], b.name)) || (matches([q], fitting.item) && matches([p], b.name)))) continue;
        let best = { d: BIG, j: -1 };
        for (let j = 0; j < b.screen.length; j++) {
          const d = Math.hypot(b.screen[j][0] - x, b.screen[j][1] - y);
          if (d < best.d) best = { d, j };
        }
        const touch = best.d - reach * cam.k - b.width;
        if (touch >= clear) continue;
        const front = frontEntry(entries, cam, x, y);
        if (!(front === fitting || belongs(front, fitting.owner, fitting.bundle))) continue;
        const id = [fitting.item, b.name].sort().join("|");
        const prior = found.get(id);
        if (!prior || Math.abs(touch) < Math.abs(prior.gap)) found.set(id, { a: fitting.item, b: b.name, where: "fitting", gap: touch, depth: Math.abs(dot3(sub3(shape.F.o, b.dense[best.j]), cam.V)), need: 0, screen: [x, y], at: shape.F.o, state: state === undefined ? "" : labelOf(state) });
      }
    }
  return [...found.values()].sort((x, y) => Math.abs(x.gap) - Math.abs(y.gap));
}

const multiply = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];

function matrixOf(text) {
  let m = [1, 0, 0, 1, 0, 0];
  if (!text) return m;
  for (const [, kind, args] of text.matchAll(/(matrix|translate|scale|rotate)\s*\(([^)]*)\)/g)) {
    const v = (args.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []).map(Number);
    let n = [1, 0, 0, 1, 0, 0];
    if (kind === "matrix" && v.length === 6) n = v;
    else if (kind === "translate") n = [1, 0, 0, 1, v[0] ?? 0, v[1] ?? 0];
    else if (kind === "scale") n = [v[0] ?? 1, 0, 0, v[1] ?? v[0] ?? 1, 0, 0];
    else if (kind === "rotate") {
      const a = ((v[0] ?? 0) * Math.PI) / 180;
      const [cx, cy] = [v[1] ?? 0, v[2] ?? 0];
      n = multiply(multiply([1, 0, 0, 1, cx, cy], [Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0]), [1, 0, 0, 1, -cx, -cy]);
    }
    m = multiply(m, n);
  }
  return m;
}

const NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi;

function pointsOfShape(tag, attrs) {
  const get = (name) => {
    const hit = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
    return hit ? hit[1] : null;
  };
  const num = (name) => Number(get(name) ?? 0);
  if (tag === "path") {
    const d = get("d") ?? "";
    const out = [];
    let x = 0;
    let y = 0;
    for (const [, command, args] of d.matchAll(/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g)) {
      const v = (args.match(NUMBER) ?? []).map(Number);
      const rel = command === command.toLowerCase();
      const C = command.toUpperCase();
      const pair = (i) => [v[i] + (rel ? x : 0), v[i + 1] + (rel ? y : 0)];
      const take = (p) => {
        out.push(p);
        return p;
      };
      if (C === "H") for (const value of v) [x] = take([value + (rel ? x : 0), y]);
      else if (C === "V") for (const value of v) [, y] = take([x, value + (rel ? y : 0)]);
      else if (C === "A") for (let i = 0; i + 6 < v.length; i += 7) [x, y] = take([v[i + 5] + (rel ? x : 0), v[i + 6] + (rel ? y : 0)]);
      else if (C !== "Z") {
        const width = C === "C" ? 6 : C === "S" || C === "Q" ? 4 : 2;
        for (let i = 0; i + 1 < v.length; i += width) {
          for (let j = 0; j < width; j += 2) out.push(pair(i + j));
          [x, y] = pair(i + width - 2);
        }
      }
    }
    return out;
  }
  if (tag === "circle" || tag === "ellipse") {
    const r = Number(get("r") ?? get("rx") ?? 0);
    const ry = Number(get("r") ?? get("ry") ?? 0);
    return [[num("cx") - r, num("cy") - ry], [num("cx") + r, num("cy") + ry]];
  }
  if (tag === "line") return [[num("x1"), num("y1")], [num("x2"), num("y2")]];
  if (tag === "rect") return [[num("x"), num("y")], [num("x") + num("width"), num("y") + num("height")]];
  if (tag === "polyline" || tag === "polygon") {
    const v = (get("points") ?? "").match(NUMBER)?.map(Number) ?? [];
    const out = [];
    for (let i = 0; i + 1 < v.length; i += 2) out.push([v[i], v[i + 1]]);
    return out;
  }
  return [];
}

const boxOfPoints = (points, m) => {
  let box = null;
  for (const [x, y] of points) {
    const px = m[0] * x + m[2] * y + m[4];
    const py = m[1] * x + m[3] * y + m[5];
    box = box ? [Math.min(box[0], px), Math.min(box[1], py), Math.max(box[2], px), Math.max(box[3], py)] : [px, py, px, py];
  }
  return box;
};
const unionBox = (a, b) => (!a ? b : !b ? a : [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]);
const meetBox = (a, b) => (!a || !b ? a ?? null : [Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
const areaOf = (box) => (box ? Math.max(0, box[2] - box[0]) * Math.max(0, box[3] - box[1]) : 0);

export function markupBox(svg) {
  const tags = [...svg.matchAll(/<(\/?)([a-zA-Z]+)([^>]*?)(\/?)>/g)];
  const clips = new Map();
  let clipId = null;
  for (const [, close, tag, attrs, self] of tags) {
    if (tag === "clipPath" && !close) {
      clipId = self ? null : (attrs.match(/id="([^"]*)"/) ?? [])[1] ?? null;
      continue;
    }
    if (clipId) {
      if (tag === "clipPath" && close) {
        clipId = null;
        continue;
      }
      clips.set(clipId, unionBox(clips.get(clipId), boxOfPoints(pointsOfShape(tag, attrs), matrixOf((attrs.match(/transform="([^"]*)"/) ?? [])[1]))));
    }
  }
  const stack = [{ m: [1, 0, 0, 1, 0, 0], clip: null, skip: false }];
  let box = null;
  for (const [, close, tag, attrs, self] of tags) {
    const top = stack[stack.length - 1];
    if (close) {
      if (stack.length > 1) stack.pop();
      continue;
    }
    const m = multiply(top.m, matrixOf((attrs.match(/transform="([^"]*)"/) ?? [])[1]));
    const ref = (attrs.match(/clip-path="url\(#([^)]*)\)"/) ?? [])[1];
    const own = ref && clips.get(ref) ? boxOfPoints([[clips.get(ref)[0], clips.get(ref)[1]], [clips.get(ref)[2], clips.get(ref)[3]]], m) : null;
    const frame = { m, clip: own ? meetBox(top.clip ?? own, own) : top.clip, skip: top.skip || ["defs", "clipPath", "mask", "linearGradient", "radialGradient", "pattern", "text", "title"].includes(tag) || /class="[^"]*iso-halo/.test(attrs) };
    if (!frame.skip) {
      const shape = boxOfPoints(pointsOfShape(tag, attrs), m);
      if (shape) box = unionBox(box, frame.clip ? meetBox(shape, frame.clip) : shape);
    }
    if (!self) stack.push(frame);
  }
  return box;
}

export function footprintOf(shape, cam) {
  const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
  if (shape.kind === "body") {
    const { F } = shape;
    const points = [];
    for (const [s, r] of shape.poly) {
      if (r <= 0) {
        points.push(flat(add3(F.o, mul3(F.a, s))));
        continue;
      }
      for (let i = 0; i < 16; i++) {
        const a = (i / 16) * Math.PI * 2;
        points.push(flat(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, r * Math.cos(a)), mul3(F.v, r * Math.sin(a))))));
      }
    }
    return boxOfPoints(points, [1, 0, 0, 1, 0, 0]);
  }
  if (shape.kind === "prism") {
    const { F } = shape;
    const points = [];
    for (const s of [shape.s0, shape.s1]) for (const [x, y] of shape.poly) points.push(flat(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)))));
    return boxOfPoints(points, [1, 0, 0, 1, 0, 0]);
  }
  const [x0, y0, x1, y1] = screenBox(shape, cam, [0, 0, 0]);
  return [x0, y0, x1, y1];
}

export function coverage(items, P, { least = 0.6, pad = 1, allow = [] } = {}) {
  const cam = cameraBasis(P);
  const out = [];
  for (const item of items) {
    if (typeof item.svg !== "string" || !item.shapes || !item.shapes.length || matches(allow, item.name)) continue;
    const drawn = markupBox(item.svg);
    if (!drawn) continue;
    const grow = (box) => [box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad];
    const shapes = item.shapes.map((shape) => footprintOf(shape, cam)).reduce(unionBox, null);
    const share = areaOf(meetBox(grow(drawn), grow(shapes))) / (areaOf(grow(drawn)) || 1);
    if (share < least) out.push({ name: item.name, share, drawn, shapes });
  }
  return out.sort((a, b) => a.share - b.share);
}

export function selfOverlaps(items, P, { entries: given = null, fold = 0.25, least = 0.5 } = {}) {
  const cam = cameraBasis(P);
  const entries = given ?? entriesOf(items, cam);
  const flat = (p) => [cam.origin[0] + dot3(p, cam.R) * cam.k, cam.origin[1] + dot3(p, cam.D) * cam.k];
  const out = [];
  for (const item of items) {
    if (!item.route || !item.shapes || !item.shapes.length || item.shapes[0].kind !== "tube") continue;
    const shape = item.shapes[0];
    const r = shape.radii.reduce((sum, value) => sum + value, 0) / shape.radii.length;
    const dense = resample(shape.points, Math.max(0.1, r * 0.2));
    const screen = dense.map(flat);
    const n = screen.length;
    if (n < 4) continue;
    const width = r * cam.k;
    const span = 3;
    const normals = screen.map((_, i) => {
      const a = screen[Math.max(0, i - span)];
      const b = screen[Math.min(n - 1, i + span)];
      const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
      return [-(b[1] - a[1]) / length, (b[0] - a[0]) / length];
    });
    let worst = null;
    for (const side of [1, -1]) {
      const edge = screen.map((p, i) => [p[0] + side * normals[i][0] * width, p[1] + side * normals[i][1] * width]);
      for (let i = span; i < n - span - 1; i++) {
        const along = [screen[i + 1][0] - screen[i][0], screen[i + 1][1] - screen[i][1]];
        const step = [edge[i + 1][0] - edge[i][0], edge[i + 1][1] - edge[i][1]];
        const length = Math.hypot(along[0], along[1]);
        if (length < 1e-6) continue;
        const forward = (step[0] * along[0] + step[1] * along[1]) / length;
        if (forward > -fold * length) continue;
        const x = (screen[i][0] + screen[i + 1][0]) / 2;
        const y = (screen[i][1] + screen[i + 1][1]) / 2;
        const front = frontEntry(entries, cam, x, y);
        if (front && front.owner !== item.route && front.item !== item.name) continue;
        const radius = Math.abs(forward / length - 1) > 1e-6 ? width / Math.abs(1 - forward / length) : Infinity;
        if (width - radius < least) continue;
        if (!worst || radius < worst.radius) worst = { name: item.name, route: item.route, radius, width, at: dense[i], screen: [x, y] };
      }
    }
    if (worst) out.push(worst);
  }
  return out;
}

export function solidClearances(solids, { state, sunk = 0.5, spacing = 0.6, cell = 4 } = {}) {
  const list = solids.filter((shape) => shape.kind !== "tube").map((shape) => (shape.slide && state !== undefined && shape.move ? { ...moved(shape, offsetOf(shape, state)), move: undefined } : shape));
  if (!solidClearances.cache) solidClearances.cache = new WeakMap();
  const cache = solidClearances.cache;
  const gridOf = (shape) => {
    let grid = cache.get(shape);
    if (grid) return grid;
    grid = new Map();
    for (const p of surfaceOf(shape, spacing)) {
      const key = p.map((v) => Math.floor(v / cell)).join(",");
      if (!grid.has(key)) grid.set(key, []);
      grid.get(key).push(p);
    }
    cache.set(shape, grid);
    return grid;
  };
  const pointsIn = (shape, box) => {
    const grid = gridOf(shape);
    const lo = box.min.map((v) => Math.floor(v / cell));
    const hi = box.max.map((v) => Math.floor(v / cell));
    const out = [];
    if ((hi[0] - lo[0] + 1) * (hi[1] - lo[1] + 1) * (hi[2] - lo[2] + 1) > grid.size) {
      for (const points of grid.values()) for (const p of points) if (p.every((v, i) => v >= box.min[i] && v <= box.max[i])) out.push(p);
      return out;
    }
    for (let x = lo[0]; x <= hi[0]; x++)
      for (let y = lo[1]; y <= hi[1]; y++)
        for (let z = lo[2]; z <= hi[2]; z++) {
          const points = grid.get(`${x},${y},${z}`);
          if (points) for (const p of points) if (p.every((v, i) => v >= box.min[i] && v <= box.max[i])) out.push(p);
        }
    return out;
  };
  const offsets = list.map((shape) => offsetAt(shape, state));
  const bounds = list.map((shape) => boundsOf(shape));
  const found = [];
  for (let a = 0; a < list.length; a++)
    for (let b = a + 1; b < list.length; b++) {
      const rel = sub3(offsets[a], offsets[b]);
      if (Math.abs(rel[0]) + Math.abs(rel[1]) + Math.abs(rel[2]) < 1e-6) continue;
      const box = { min: [0, 1, 2].map((i) => Math.max(bounds[a].min[i] + rel[i], bounds[b].min[i])), max: [0, 1, 2].map((i) => Math.min(bounds[a].max[i] + rel[i], bounds[b].max[i])) };
      if (box.min.some((v, i) => v > box.max[i] - sunk)) continue;
      if (seatedOn(list[a], list[b]) || seatedOn(list[b], list[a])) continue;
      let worst = -sunk;
      let at = null;
      const local = { min: sub3(box.min, rel), max: sub3(box.max, rel) };
      for (const p of pointsIn(list[a], local)) {
        const d = sdf(list[b], add3(p, rel));
        if (d < worst) (worst = d), (at = add3(p, offsets[a]));
      }
      for (const p of pointsIn(list[b], box)) {
        const d = sdf(list[a], sub3(p, rel));
        if (d < worst) (worst = d), (at = add3(p, offsets[b]));
      }
      if (at) found.push({ tube: list[a].name, other: list[b].name, kind: "through", solid: true, clearance: worst, need: -sunk, at, from: 0, to: 0, length: 0, state: state === undefined ? "" : labelOf(state) });
    }
  return found.sort((x, y) => x.clearance - y.clearance);
}

export function wobble(solids, { samples = 20 } = {}) {
  const out = [];
  for (const shape of solids) {
    const profile = shape.curve;
    if (!profile) continue;
    const segments = curveRuns(profile);
    let run = [];
    const flush = () => {
      if (run.length) {
        const values = [];
        for (const index of run) {
          const [s0] = profile[index];
          const [s1] = profile[index + 1];
          for (let j = values.length ? 1 : 0; j <= samples; j++) {
            const s = s0 + ((s1 - s0) * j) / samples;
            values.push([s, segments[index].slope(s)]);
          }
        }
        const scale = Math.max(1, ...values.map(([, g]) => Math.abs(g))) * 1e-6;
        const turns = [];
        let last = 0;
        for (let i = 1; i < values.length; i++) {
          const d = values[i][1] - values[i - 1][1];
          if (Math.abs(d) <= scale) continue;
          const sign = Math.sign(d);
          if (last && sign !== last) turns.push(Math.round(values[i - 1][0] * 100) / 100);
          last = sign;
        }
        if (turns.length) out.push({ name: shape.name, at: turns, from: profile[run[0]][0], to: profile[run[run.length - 1] + 1][0] });
      }
      run = [];
    };
    segments.forEach((segment, index) => (segment ? run.push(index) : flush()));
    flush();
  }
  return out;
}

export function seats(solids) {
  const out = [];
  for (const shape of solids) {
    if (!shape.seated || !shape.cut) continue;
    for (const host of [shape.cut].flat()) {
      const mine = mul3(shape.move ?? [0, 0, 0], 1);
      const theirs = mul3(host.move ?? [0, 0, 0], 1);
      if ((shape.ride ?? shape.piece) === (host.ride ?? host.piece) && sameOffset(mine, theirs)) continue;
      out.push({ name: shape.name, host: host.name, piece: shape.ride ?? shape.piece, hostPiece: host.ride ?? host.piece });
    }
  }
  return out;
}

export function report({ clearance = [], depth = { problems: [], missing: [] }, terminals: ends, folds: bends, supports: held, loose, apart, flight, crowding: crowded, self, wobble: wobbly, seats: seated, cover: covered } = {}, { limit = 4000 } = {}) {
  const lines = [];
  const f = (v) => (Math.round(v * 100) / 100).toFixed(2);
  const p3 = (p) => `[${p.map((v) => v.toFixed(1)).join(", ")}]`;
  const clash = (c) => (c.solid ? `  solid   ${c.state ? c.state + "  " : ""}${c.tube}  ×  ${c.other}   sunk ${f(-c.clearance)} deep   at ${p3(c.at)}` : `  ${c.kind.padEnd(7)} ${c.state ? c.state + "  " : ""}${c.tube}  ×  ${c.other}   clearance ${f(c.clearance)} < ${f(c.need)}   along ${f(c.from)}–${f(c.to)} of ${f(c.length)}   at ${p3(c.at)}`);
  lines.push(`clearance: ${clearance.length} problem${clearance.length === 1 ? "" : "s"}`);
  for (const c of clearance.slice(0, limit)) lines.push(clash(c));
  if (apart) {
    lines.push(`clearance apart: ${apart.length} problem${apart.length === 1 ? "" : "s"}`);
    for (const c of apart.slice(0, limit)) lines.push(clash(c));
  }
  if (ends) {
    lines.push(`terminals: ${ends.length} end${ends.length === 1 ? "" : "s"} not on a mount`);
    for (const e of ends.slice(0, limit)) lines.push(`  ${e.tube} ${e.end}  ${Number.isFinite(e.d) ? f(e.d) : "∞"} from ${e.nearest} (rim ${Number.isFinite(e.high) ? `${f(e.low)}…${f(e.high)}` : "-"})   at ${p3(e.at)}`);
  }
  if (loose) {
    const hanging = loose.filter((e) => !e.free);
    lines.push(`loose ends: ${hanging.length}`);
    for (const e of hanging.slice(0, limit)) lines.push(`  ${e.state}  ${e.tube} ${e.end} touches nothing that moves with it   at ${p3(e.at)}`);
  }
  if (bends) {
    lines.push(`screen folds: ${bends.length}`);
    for (const b of bends.slice(0, limit))
      lines.push(b.kind === "pinch" ? `  ${b.name}  turns ${b.flatTurn.toFixed(0)}° on screen round a ${f(b.radius)} px bend, under ${f(b.width * 2)} px for a ${f(b.width)} px tube   at ${p3(b.at)}` : `  ${b.name}  turns ${b.flatTurn.toFixed(0)}° on screen for ${b.realTurn.toFixed(0)}° in 3D   at ${p3(b.at)}`);
  }
  if (held) {
    const hanging = held.hanging ?? [];
    lines.push(`support: ${held.loose.length} unsupported solid${held.loose.length === 1 ? "" : "s"} · ${hanging.length} hanging tube${hanging.length === 1 ? "" : "s"} · ${held.sunk.length} sunk pair${held.sunk.length === 1 ? "" : "s"}`);
    for (const name of held.loose.slice(0, limit)) lines.push(`  loose   ${name}`);
    for (const name of hanging.slice(0, limit)) lines.push(`  hanging ${name}`);
    for (const d of held.sunk.slice(0, limit)) lines.push(`  sunk    ${d.a} × ${d.b}   ${f(d.depth)} deep`);
  }
  lines.push(`depth order: ${depth.problems.length} problem${depth.problems.length === 1 ? "" : "s"}`);
  for (const d of depth.problems.slice(0, limit))
    lines.push(`  ${d.cycle ? "cycle " : "order "} ${d.label}  ${d.front} (k ${f(d.frontKey)}) is in front of ${d.back} (k ${f(d.backKey)}) but drawn first   ${f(d.area)} px²${d.against ? ` (${f(d.against)} px² the other way)` : ""}, up to ${f(d.depth)} nearer, at ${d.at.map((v) => v.toFixed(1)).join(",")}`);
  if (flight) {
    lines.push(`through in mid-flight: ${flight.length} pair${flight.length === 1 ? "" : "s"}`);
    for (const c of flight.slice(0, limit)) lines.push(clash(c));
  }
  if (self) {
    lines.push(`self-overlap: ${self.length} chunk${self.length === 1 ? "" : "s"} whose outline crosses itself on screen`);
    for (const c of self.slice(0, limit)) lines.push(`  ${c.name}  its outline folds back on screen: the line turns on a ${f(c.radius)} px radius, under its ${f(c.width)} px half-width   at ${p3(c.at)}, screen ${c.screen.map((v) => v.toFixed(1)).join(",")}`);
  }
  if (wobbly) {
    lines.push(`wobble: ${wobbly.length} smoothed profile${wobbly.length === 1 ? "" : "s"} whose slope turns between its ends`);
    for (const w of wobbly.slice(0, limit)) lines.push(`  ${w.name}  dr/ds turns at s = ${w.at.join(", ")} on the run ${f(w.from)}–${f(w.to)}; give it an analytic profile and smooth.slope`);
  }
  if (seated) {
    lines.push(`saddles off their host apart: ${seated.length}`);
    for (const d of seated.slice(0, limit)) lines.push(`  ${d.name} (${d.piece}) is cut to ${d.host} (${d.hostPiece}); apart, its cut base is on show`);
  }
  if (covered) {
    lines.push(`stand-in shapes: ${covered.length} item${covered.length === 1 ? "" : "s"} whose recorded shape covers too little of what it draws`);
    for (const c of covered.slice(0, limit)) lines.push(`  ${c.name}  its shapes cover ${Math.round(c.share * 100)}% of its drawing (drawn ${c.drawn.map((v) => v.toFixed(0)).join(",")}, recorded ${c.shapes.map((v) => v.toFixed(0)).join(",")}); record the true shape of what you draw`);
  }
  if (crowded) {
    lines.push(`crowding: ${crowded.length} pair${crowded.length === 1 ? "" : "s"} where a bend or end touches another line on screen`);
    for (const c of crowded.slice(0, limit)) lines.push(`  ${c.state ? c.state + "  " : ""}${c.a} ${c.where}  ×  ${c.b}   outlines ${c.gap < 0 ? `overlap ${f(-c.gap)}` : `${f(c.gap)} apart`} px, ${f(c.depth)} apart in depth   at screen ${c.screen.map((v) => v.toFixed(1)).join(",")}`);
  }
  if (depth.passing && depth.passing.length) {
    const pairs = (list) => new Set(list.map((d) => [d.front, d.back].sort().join("|"))).size;
    lines.push(`in passing (uniform explode states only, not failing): ${pairs(depth.passing.filter((d) => d.cycle))} interlocked pairs, ${pairs(depth.passing.filter((d) => !d.cycle))} pairs no linear key can order`);
    for (const d of depth.passing.slice(0, limit)) lines.push(`  ${d.cycle ? "cycle " : "unorderable"} ${d.label}  ${d.front} / ${d.back}   ${f(d.area)} px²`);
  }
  if (depth.missing.length) lines.push(`items with no shape: ${depth.missing.length}  ${depth.missing.slice(0, 20).join(", ")}`);
  if (loose && loose.some((e) => e.free)) {
    const free = loose.filter((e) => e.free);
    lines.push(`free ends apart (marked free, not failing): ${free.length}`);
    for (const e of free.slice(0, limit)) lines.push(`  ${e.tube} ${e.end}   at ${p3(e.at)}`);
  }
  return lines.join("\n");
}

export const failures = ({ clearance = [], depth = { problems: [], missing: [] }, terminals: ends = [], folds: bends = [], supports: held = { loose: [], sunk: [] }, loose = [], apart = [], flight = [], crowding: crowded = [], self = [], wobble: wobbly = [], seats: seated = [], cover: covered = [] }) =>
  covered.length + clearance.length + apart.length + flight.length + crowded.length + ends.length + loose.filter((e) => !e.free).length + bends.length + held.loose.length + (held.hanging ?? []).length + held.sunk.length + depth.problems.length + depth.missing.length + self.length + wobbly.length + seated.length;

export const sameRun = (a, b) => Boolean(a.route) && a.route === b.route && Math.abs(a.chunk - b.chunk) <= 1;

export function recorder(P, { order = [], spring = {}, ground = grounded } = {}) {
  const V = cameraBasis(P).V;
  const counts = new Map();
  const R = { P, V, order, spring, ground, items: [], solids: [], tubes: [], routes: [], settled: null };
  R.nameFor = (base) => {
    const count = (counts.get(base) ?? 0) + 1;
    counts.set(base, count);
    return count === 1 ? base : `${base}#${count}`;
  };
  R.solid = (shape) => {
    R.solids.push(shape);
    return shape;
  };
  R.put = ({ at = null, bias = 0, key, name, piece = "", move = [0, 0, 0], ride, shapes = [], ...rest } = {}) => {
    const item = { ...rest, piece, ride: ride ?? piece, key: key ?? (at ? dot3(at, V) : 0) + bias, move, shapes, name: name ?? R.nameFor(`${piece || "item"}.item`) };
    for (const shape of shapes) (shape.move = item.move), (shape.ride = item.ride), (shape.piece = piece);
    R.items.push(item);
    return item;
  };
  R.route = (name, points, radius, { owner = name, touch, bundle, limp = false, gaps = [], closed = false, ends = [true, true], rims, free = [false, false], piece = "", move = [0, 0, 0], ride = piece } = {}) => {
    const total = pathLength(points);
    const radiusOf = typeof radius === "function" ? radius : () => radius;
    const runs = runsOf(total, gaps);
    const recorded = runs.map(([from, to], index) => ({
      ...tube(cutAlong(points, from, to), (t) => radiusOf((from + (to - from) * t) / total), { name, owner, touch, bundle, limp, joined: true }),
      tips: closed ? [null, null] : [index === 0 && ends[0] ? points[0] : null, index === runs.length - 1 && ends[1] ? points[points.length - 1] : null],
      tipAxes: [tangentAlong(points, 0), tangentAlong(points, total)],
      tipRims: rims ?? [radiusOf(0), radiusOf(1)],
      free: [index === 0 && free[0], index === runs.length - 1 && free[1]],
      offset: from,
      total,
      move,
      ride,
      piece,
    }));
    R.tubes.push(...recorded);
    R.routes.push({ name, points, r: radiusOf(0.5), bundle, move, ride, piece });
    return recorded;
  };
  return R;
}

export function explodeStates(order = [], spring = {}, { uniform = 10, stride = 4 } = {}) {
  if (!order.length) return { depth: [0], frames: [], apart: false };
  const reversals = reversalsOf(order, spring.stagger ?? 0.035);
  const prefix = prefixStates(order);
  return {
    depth: [...uniformStates(uniform), ...springStates(order, { ...spring, stride, reversals }), ...prefix],
    frames: [...springStates(order, { ...spring, reversals }), ...prefix],
    apart: true,
  };
}

export function settle(R, P = R.P, { states = explodeStates(R.order, R.spring).depth, skip = sameRun, ...options } = {}) {
  const items = R.items.slice();
  const overlaps = overlapsOf(items, P, { states, skip, ...options });
  const solved = orderKeys(items, overlaps);
  items.forEach((item, index) => (item.key = solved.keys[index]));
  R.settled = { items, overlaps, solved, states };
  return R.settled;
}

const worstOf = (lists) => [...lists.flat().reduce((all, c) => (all.has(`${c.tube}|${c.other}`) && all.get(`${c.tube}|${c.other}`).clearance <= c.clearance ? all : all.set(`${c.tube}|${c.other}`, c)), new Map()).values()].sort((x, y) => x.clearance - y.clearance);

export function audit(R, P = R.P, { allow = [], crowd = [], cover = [], ground = R.ground, frames = explodeStates(R.order, R.spring).frames, apart = R.order.length > 0 } = {}) {
  const { items, overlaps, solved } = R.settled ?? settle(R, P);
  const mounts = mountsOf(R.tubes, R.solids);
  const links = linksOf(R.tubes, mounts);
  return {
    clearance: clearances(R.tubes, R.solids, { mounts }),
    apart: apart ? [...clearances(R.tubes, R.solids, { state: 1, mounts, only: "through" }), ...solidClearances(R.solids, { state: 1 })] : [],
    flight: worstOf(frames.map((state) => [...clearances(R.tubes, R.solids, { state, mounts, only: "through" }), ...solidClearances(R.solids, { state })])),
    crowding: (apart ? [0, 1] : [0]).flatMap((state) => crowding(R.routes, P, { links, items, allow: crowd, state, rods: slenderOf(R.solids) })),
    cover: coverage(items, P, { allow: cover }),
    terminals: terminals(R.tubes, R.solids, { mounts }),
    loose: looseEnds(R.tubes, R.solids, { state: 1 }),
    folds: folds(R.routes, P, { items }),
    self: selfOverlaps(items, P),
    wobble: wobble(R.solids),
    seats: seats(R.solids),
    supports: supports(R.solids, R.tubes, { ground }),
    depth: depthOrder(items, P, { overlaps, solved, allow }),
  };
}

export function auditOrExit(R, P = R.P, { flag = "--audit", argv = process.argv, ...options } = {}) {
  if (!argv.includes(flag)) return null;
  const started = Date.now();
  const result = audit(R, P, options);
  const { overlaps, solved, states } = R.settled;
  console.log(report(result));
  console.log(`audit · ${R.tubes.length} tubes · ${R.solids.length} solids · ${R.items.length} items · ${states.length} depth states · ${overlaps.pairs.length} overlaps · ${solved.rules} order rules · ${((Date.now() - started) / 1000).toFixed(1)} s`);
  const count = failures(result);
  console.log(count ? `audit failed: ${count} problem${count === 1 ? "" : "s"}` : "audit passed");
  if (count) process.exitCode = 1;
  return result;
}
```

## kit/canvas-painter.mjs

```js
const CP_Q = 64;
const CP_SVG = "http://www.w3.org/2000/svg";

function cpScratch() {
  const c = document.createElement("canvas");
  c.width = c.height = 1;
  return c.getContext("2d");
}

function cpRgba(css, probe) {
  if (!css || css === "none" || css.startsWith("url(")) return null;
  probe.fillStyle = "#000";
  probe.fillStyle = css;
  const out = probe.fillStyle;
  if (out[0] === "#") return [parseInt(out.slice(1, 3), 16), parseInt(out.slice(3, 5), 16), parseInt(out.slice(5, 7), 16), 1];
  const nums = out.match(/-?[\d.]+(e-?\d+)?/g);
  if (!nums) return null;
  if (out.startsWith("color(")) {
    const v = nums.map(Number);
    return [v[0] * 255, v[1] * 255, v[2] * 255, v.length > 3 ? v[3] : 1];
  }
  const v = nums.map(Number);
  return [v[0], v[1], v[2], v.length > 3 ? v[3] : 1];
}

function cpCss(rgba, alpha = 1) {
  const a = rgba[3] * alpha;
  const r = Math.round(rgba[0]);
  const g = Math.round(rgba[1]);
  const b = Math.round(rgba[2]);
  return a >= 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${Math.round(a * 1000) / 1000})`;
}

function cpMatrixOf(text) {
  if (!text) return null;
  const nums = text.match(/-?[\d.]+(e-?\d+)?/g);
  if (!nums) return null;
  const v = nums.map(Number);
  if (text.startsWith("translate")) return [1, 0, 0, 1, v[0], v[1] ?? 0];
  if (text.startsWith("matrix")) return v;
  const m = new DOMMatrix(text);
  return [m.a, m.b, m.c, m.d, m.e, m.f];
}

function cpMul(A, B) {
  if (!A) return B;
  if (!B) return A;
  return [A[0] * B[0] + A[2] * B[1], A[1] * B[0] + A[3] * B[1], A[0] * B[2] + A[2] * B[3], A[1] * B[2] + A[3] * B[3], A[0] * B[4] + A[2] * B[5] + A[4], A[1] * B[4] + A[3] * B[5] + A[5]];
}

function cpStaticMatrix(el) {
  const list = el.transform && el.transform.baseVal;
  if (!list || !list.numberOfItems) return null;
  const m = list.consolidate().matrix;
  return [m.a, m.b, m.c, m.d, m.e, m.f];
}

function cpGeometry(el) {
  const tag = el.localName;
  const num = (name) => parseFloat(el.getAttribute(name) ?? "0") || 0;
  if (tag === "path") return el.getAttribute("d") ? new Path2D(el.getAttribute("d")) : null;
  const p = new Path2D();
  if (tag === "circle") p.arc(num("cx"), num("cy"), num("r"), 0, Math.PI * 2);
  else if (tag === "ellipse") p.ellipse(num("cx"), num("cy"), num("rx"), num("ry"), 0, 0, Math.PI * 2);
  else if (tag === "rect") {
    const rx = num("rx") || num("ry");
    if (rx && p.roundRect) p.roundRect(num("x"), num("y"), num("width"), num("height"), rx);
    else p.rect(num("x"), num("y"), num("width"), num("height"));
  } else if (tag === "line") {
    p.moveTo(num("x1"), num("y1"));
    p.lineTo(num("x2"), num("y2"));
  } else if (tag === "polyline" || tag === "polygon") {
    const v = (el.getAttribute("points") ?? "").trim().split(/[\s,]+/).map(Number);
    for (let i = 0; i + 1 < v.length; i += 2) i ? p.lineTo(v[i], v[i + 1]) : p.moveTo(v[i], v[i + 1]);
    if (tag === "polygon") p.closePath();
  } else return null;
  return p;
}

function cpStyle(el, probe) {
  const cs = getComputedStyle(el);
  if (cs.display === "none" || cs.visibility === "hidden") return null;
  const fill = cpRgba(cs.fill, probe);
  const stroke = cpRgba(cs.stroke, probe);
  const fillOpacity = parseFloat(cs.fillOpacity);
  const strokeOpacity = parseFloat(cs.strokeOpacity);
  const dash = cs.strokeDasharray && cs.strokeDasharray !== "none" ? cs.strokeDasharray.split(/[\s,]+/).map(parseFloat).filter((v) => !Number.isNaN(v)) : null;
  return {
    cs,
    fill: fill ? cpCss(fill, isNaN(fillOpacity) ? 1 : fillOpacity) : null,
    fillRaw: fill ? cpCss(fill) : null,
    stroke: stroke ? cpCss(stroke, isNaN(strokeOpacity) ? 1 : strokeOpacity) : null,
    width: parseFloat(cs.strokeWidth) || 0,
    cap: cs.strokeLinecap || "butt",
    join: cs.strokeLinejoin || "miter",
    miter: parseFloat(cs.strokeMiterlimit) || 4,
    dash,
    dashOffset: parseFloat(cs.strokeDashoffset) || 0,
    rule: cs.fillRule === "evenodd" ? "evenodd" : "nonzero",
    opacity: parseFloat(cs.opacity),
    nonScaling: cs.vectorEffect === "non-scaling-stroke",
  };
}

function cpCompilePart(node, run, probe, unsupported, vary) {
  const ops = [];
  const roleOf = (el) => {
    const name = el === node ? "move" : el.getAttribute("data-r");
    if (name === null || run.role[name] === undefined) return -1;
    return run.role[name];
  };
  const varyOf = (el) => {
    if (!vary || !el.classList) return null;
    for (const cls of el.classList) if (vary[cls]) return vary[cls];
    return null;
  };
  const walk = (el, chain, alphaRoles, alpha, varies = []) => {
    const varied = varyOf(el);
    const ownVaries = varied ? varies.concat([varied]) : varies;
    const r = roleOf(el);
    const type = r >= 0 ? run.roles[r].type : null;
    const tag = el.localName;
    let nextChain = chain;
    if (type === "self" || type === "plane" || type === "xform") nextChain = chain.concat([{ r }]);
    else {
      const m = el === node ? null : cpStaticMatrix(el);
      if (m) nextChain = chain.concat([{ m }]);
    }
    if (tag === "g" || tag === "svg") {
      let groupAlpha = alpha;
      let groupRoles = alphaRoles;
      if (type === "plane") groupRoles = alphaRoles.concat([r]);
      else if (varied) {
      } else if (el !== node) {
        const op = parseFloat(getComputedStyle(el).opacity);
        if (!Number.isNaN(op)) groupAlpha *= op;
      }
      for (const child of el.children) walk(child, nextChain, groupRoles, groupAlpha, ownVaries);
      return;
    }
    if (tag === "title" || tag === "desc") return;
    const style = cpStyle(el, probe);
    if (!style) return;
    let ownAlpha = alpha;
    let ownRoles = alphaRoles;
    if (type === "slot") ownRoles = alphaRoles.concat([r]);
    else if (!varied && !Number.isNaN(style.opacity)) ownAlpha *= style.opacity;
    const op = { tag, r, type, chain: nextChain, alpha: ownAlpha, alphaRoles: ownRoles, varies: ownVaries, style, path: null, size: 0, last: null };
    if (type === "face") {
      const cs = style.cs;
      op.ramp = [0, 1, 2, 3, 4].map((i) => cpRgba(cs.getPropertyValue(`--tn-${i}`).trim(), probe) ?? [0, 0, 0, 1]);
      op.rampKey = op.ramp.map((c) => c.join(",")).join("|");
      op.baseWidth = style.width;
    } else if (type === "dot") {
      op.size = parseFloat(el.getAttribute("r")) || 0.5;
    } else if (type === "path" || type === "slot") {
    } else if (tag === "text") {
      const cs = style.cs;
      op.text = el.textContent;
      op.x = parseFloat(el.getAttribute("x") ?? "0") || 0;
      op.y = parseFloat(el.getAttribute("y") ?? "0") || 0;
      op.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
      op.align = cs.textAnchor === "middle" ? "center" : cs.textAnchor === "end" ? "right" : "left";
      op.spacing = cs.letterSpacing === "normal" ? "0px" : cs.letterSpacing;
      op.baseline = cs.dominantBaseline === "central" || cs.dominantBaseline === "middle" ? "middle" : cs.dominantBaseline === "hanging" ? "hanging" : "alphabetic";
    } else {
      op.path = cpGeometry(el);
      if (!op.path) {
        unsupported.add(tag);
        return;
      }
    }
    ops.push(op);
  };
  walk(node, [], [], 1);
  return ops;
}

function cpTrace(ctx, d, arena) {
  ctx.beginPath();
  for (let i = 0; i + 4 < d.length; i += 5) {
    const at = 2 * (d.charCodeAt(i + 1) | (d.charCodeAt(i + 2) << 16));
    const count = d.charCodeAt(i + 3);
    ctx.moveTo(arena[at], arena[at + 1]);
    for (let k = 1; k < count; k++) ctx.lineTo(arena[at + 2 * k], arena[at + 2 * k + 1]);
    if (d.charCodeAt(i + 4)) ctx.closePath();
  }
}

function cpMount(stage, controller, { keepSvg = false, vary = null, only = null, effects = null, below = null, mask = null } = {}) {
  const probe = cpScratch();
  const scene = () => controller.scene();
  const unsupported = new Set();
  const layers = [];
  const programs = [];
  const wanted = only ?? controller.canvasLayers ?? null;
  const svgs = [...stage.querySelectorAll("svg[data-live]")].filter((svg) => !wanted || wanted.includes(svg.dataset.live));
  const first = svgs[0];
  const vb = first.viewBox.baseVal;
  const W = vb.width;
  const H = vb.height;
  const runs = scene().runs;
  const t0 = performance.now();
  for (const svg of svgs) {
    const name = svg.dataset.live;
    const canvas = document.createElement("canvas");
    canvas.className = "iso-layer iso-canvas";
    canvas.dataset.canvasLive = name;
    canvas.setAttribute("aria-hidden", "true");
    svg.before(canvas);
    const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false });
    const container = svg.querySelector(".iso-parts") ?? svg;
    for (const node of container.querySelectorAll(":scope > [data-p]")) {
      const index = Number(node.dataset.p);
      const effect = effects && effects[runs[index].name];
      programs[index] = effect === false ? [] : effect ? [{ effect }] : cpCompilePart(node, runs[index], probe, unsupported, vary);
    }
    layers.push({ name, canvas, ctx, svg });
  }
  const compileMs = performance.now() - t0;
  const effectRuns = effects ? runs.filter((run) => typeof effects[run.name] === "function" && effects[run.name].always !== false && programs[run.index]).map((run) => run.index) : [];
  const view = { zoom: 1, cx: W / 2, cy: H / 2 };
  let cssPerUnit = 1;
  let dpr = window.devicePixelRatio || 1;
  let base = [1, 0, 0, 1, 0, 0];
  const resize = () => {
    forceFull = true;
    const box = stage.getBoundingClientRect();
    dpr = window.devicePixelRatio || 1;
    const cssW = box.width;
    const cssH = box.height;
    const snapX = box.left - Math.round(box.left);
    const snapY = box.top - Math.round(box.top);
    const boxW = Math.ceil(cssW + snapX - 1e-6);
    const boxH = Math.ceil(cssH + snapY - 1e-6);
    const w = Math.max(1, Math.round(boxW * dpr));
    const h = Math.max(1, Math.round(boxH * dpr));
    for (const layer of layers) {
      if (layer.canvas.width !== w) layer.canvas.width = w;
      if (layer.canvas.height !== h) layer.canvas.height = h;
      layer.canvas.style.width = `${boxW}px`;
      layer.canvas.style.height = `${boxH}px`;
      layer.canvas.style.left = `${-snapX}px`;
      layer.canvas.style.top = `${-snapY}px`;
    }
    const fit = Math.min(cssW / W, cssH / H);
    const offX = (cssW - W * fit) / 2;
    const offY = (cssH - H * fit) / 2;
    const s = fit * view.zoom;
    cssPerUnit = s;
    const tx = offX + (W / 2) * fit - view.cx * s;
    const ty = offY + (H / 2) * fit - view.cy * s;
    base = [s * dpr, 0, 0, s * dpr, (tx + snapX) * dpr, (ty + snapY) * dpr];
    const unit = Math.round(Math.pow(2, Math.round(Math.log2(s) * 4) / 4) * 100) / 100;
    if (controller.setUnit) controller.setUnit(unit);
  };
  const faceCache = new Map();
  const faceColour = (op, q) => {
    let table = faceCache.get(op.rampKey);
    if (!table) {
      table = new Array(4 * CP_Q + 1);
      faceCache.set(op.rampKey, table);
    }
    let css = table[q];
    if (css === undefined) {
      const band = Math.min(3, Math.floor(q / CP_Q));
      const f = (q - CP_Q * band) / CP_Q;
      const a = op.ramp[band];
      const b = op.ramp[band + 1];
      css = cpCss([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f]);
      table[q] = css;
    }
    return css;
  };
  const stats = { ms: 0, ops: 0, paths: 0, parsed: 0, touched: 0, rects: 0, full: 0 };
  const lastBox = [];
  let painted = false;
  const PAD = 3;
  const rectsOf = (frames, emitted) => {
    const list = [];
    const pad = PAD / cssPerUnit;
    for (const index of emitted) {
      const b = frames[index].box;
      const old = lastBox[index];
      let x0 = b[0];
      let y0 = b[1];
      let x1 = b[2];
      let y1 = b[3];
      if (old) {
        x0 = Math.min(x0, old[0]);
        y0 = Math.min(y0, old[1]);
        x1 = Math.max(x1, old[2]);
        y1 = Math.max(y1, old[3]);
      }
      if (!(x1 >= x0)) continue;
      list.push([x0 - pad, y0 - pad, x1 + pad, y1 + pad]);
    }
    let merged = true;
    while (merged) {
      merged = false;
      for (let i = 0; i < list.length && !merged; i++)
        for (let j = i + 1; j < list.length; j++) {
          const a = list[i];
          const c = list[j];
          if (a[0] <= c[2] && c[0] <= a[2] && a[1] <= c[3] && c[1] <= a[3]) {
            list[i] = [Math.min(a[0], c[0]), Math.min(a[1], c[1]), Math.max(a[2], c[2]), Math.max(a[3], c[3])];
            list.splice(j, 1);
            merged = true;
            break;
          }
        }
    }
    return list.map(([x0, y0, x1, y1]) => {
      const dx0 = Math.floor(base[0] * x0 + base[4]);
      const dy0 = Math.floor(base[3] * y0 + base[5]);
      const dx1 = Math.ceil(base[0] * x1 + base[4]);
      const dy1 = Math.ceil(base[3] * y1 + base[5]);
      return { dx0, dy0, dx1, dy1, x0: (dx0 - base[4]) / base[0] - pad, y0: (dy0 - base[5]) / base[3] - pad, x1: (dx1 - base[4]) / base[0] + pad, y1: (dy1 - base[5]) / base[3] + pad };
    });
  };
  const keepBoxes = (frames, list) => {
    for (const index of list) {
      const b = frames[index].box;
      const slot = lastBox[index] ?? (lastBox[index] = new Float64Array(4));
      slot[0] = b[0];
      slot[1] = b[1];
      slot[2] = b[2];
      slot[3] = b[3];
    }
  };
  const pathOf = (op, d) => {
    if (op.last === d) return op.path;
    op.last = d;
    op.path = d ? new Path2D(d) : null;
    stats.parsed++;
    return op.path;
  };
  const linkMatrix = (link, f) => {
    if (link.m) return link.m;
    const t = f.t[link.r];
    if (t === "\u0002") return f.tm[link.r];
    return cpMatrixOf(t);
  };
  const chainMatrix = (chain, f) => {
    if (chain.length === 1) return linkMatrix(chain[0], f);
    let M = null;
    for (let i = 0; i < chain.length; i++) {
      const m = linkMatrix(chain[i], f);
      if (m) M = cpMul(M, m);
    }
    return M;
  };
  const tableOf = (op) => {
    let table = faceCache.get(op.rampKey);
    if (!table) {
      table = new Array(4 * CP_Q + 1);
      for (let q = 0; q <= 4 * CP_Q; q++) {
        const band = Math.min(3, Math.floor(q / CP_Q));
        const f = (q - CP_Q * band) / CP_Q;
        const a = op.ramp[band];
        const b = op.ramp[band + 1];
        table[q] = cpCss([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f]);
      }
      faceCache.set(op.rampKey, table);
    }
    return table;
  };
  const flat = (op) => ({
    kind: op.effect ? 4 : op.tag === "text" ? 3 : op.type === "dot" ? 2 : op.type === "path" || op.type === "slot" || op.type === "face" ? 1 : 0,
    face: op.type === "face",
    r: op.r ?? -1,
    chain: op.chain && op.chain.length ? op.chain : null,
    alpha: op.alpha ?? 1,
    roles: op.alphaRoles && op.alphaRoles.length ? Int32Array.from(op.alphaRoles) : null,
    varies: op.varies && op.varies.length ? op.varies : null,
    fill: op.style ? (op.type === "dot" ? op.style.fillRaw : op.style.fill) : null,
    stroke: op.style ? op.style.stroke : null,
    width: op.style ? op.style.width : 0,
    cap: op.style ? op.style.cap : "butt",
    join: op.style ? op.style.join : "miter",
    miter: op.style ? op.style.miter : 4,
    dash: op.style && op.style.dash ? op.style.dash : null,
    dashOffset: op.style ? op.style.dashOffset : 0,
    evenodd: Boolean(op.style && op.style.rule === "evenodd"),
    nonScaling: Boolean(op.style && op.style.nonScaling),
    table: op.type === "face" ? tableOf(op) : null,
    size: op.size ?? 0,
    path: op.path ?? null,
    last: null,
    text: op.text ?? "",
    x: op.x ?? 0,
    y: op.y ?? 0,
    font: op.font ?? "",
    align: op.align ?? "left",
    spacing: op.spacing ?? "0px",
    baseline: op.baseline ?? "alphabetic",
    effect: op.effect ?? null,
  });
  for (let i = 0; i < programs.length; i++) if (programs[i]) programs[i] = programs[i].map(flat);
  const paintLayer = (layer, frames, rect = null) => {
    const ctx = layer.ctx;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    if (rect) {
      ctx.save();
      ctx.beginPath();
      ctx.rect(rect.dx0, rect.dy0, rect.dx1 - rect.dx0, rect.dy1 - rect.dy0);
      ctx.clip();
      ctx.clearRect(rect.dx0, rect.dy0, rect.dx1 - rect.dx0, rect.dy1 - rect.dy0);
    } else ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
    const b0 = base[0];
    const b3 = base[3];
    const b4 = base[4];
    const b5 = base[5];
    ctx.setTransform(b0, 0, 0, b3, b4, b5);
    const order = controller.orderIndex(layer.name);
    if (!order) {
      if (rect) ctx.restore();
      return;
    }
    const unitLine = 1 / cssPerUnit;
    const seen = { x0: -b4 / b0 - 4, y0: -b5 / b3 - 4, x1: (layer.canvas.width - b4) / b0 + 4, y1: (layer.canvas.height - b5) / b3 + 4 };
    let curFill = null;
    let curStroke = null;
    let curWidth = -1;
    let curCap = null;
    let curJoin = null;
    let curMiter = -1;
    let curAlpha = 1;
    let curTransformed = false;
    let curBx = b4;
    let curBy = b5;
    let dashed = false;
    let opCount = 0;
    ctx.globalAlpha = 1;
    const drawRun = (index) => {
      const ops = programs[index];
      if (ops === undefined) return;
      const f = frames[index];
      const fb = f.box;
      if (fb[0] > seen.x1 || fb[2] < seen.x0 || fb[1] > seen.y1 || fb[3] < seen.y0) return;
      if (rect !== null) {
        if (fb[0] > rect.x1 || fb[2] < rect.x0 || fb[1] > rect.y1 || fb[3] < rect.y0) return;
        stats.touched++;
      }
      const bx = b4 + b0 * (f.sx || 0);
      const by = b5 + b3 * (f.sy || 0);
      if (bx !== curBx || by !== curBy) {
        curBx = bx;
        curBy = by;
        curTransformed = true;
      }
      for (let o = 0; o < ops.length; o++) {
        const op = ops[o];
        const kind = op.kind;
        if (kind === 4) {
          ctx.setTransform(1, 0, 0, 1, 0, 0);
          ctx.globalAlpha = 1;
          curAlpha = 1;
          op.effect(ctx, layer.canvas.width, layer.canvas.height, base, rect);
          ctx.setTransform(b0, 0, 0, b3, bx, by);
          curTransformed = false;
          curFill = null;
          curStroke = null;
          curWidth = -1;
          curCap = null;
          curJoin = null;
          curMiter = -1;
          continue;
        }
        let alpha = op.alpha;
        const roles = op.roles;
        if (roles !== null) for (let k = 0; k < roles.length; k++) alpha *= f.o[roles[k]];
        const varies = op.varies;
        if (varies !== null) for (let k = 0; k < varies.length; k++) alpha *= varies[k]();
        if (alpha <= 0.001) continue;
        let path = null;
        let token = null;
        let fill = op.fill;
        let stroke = op.stroke;
        let width = op.width;
        if (kind === 1) {
          const d = f.d[op.r];
          if (d === "" || d === undefined) continue;
          if (d.charCodeAt(0) === 1) token = d;
          else {
            path = pathOf(op, d);
            if (path === null) continue;
          }
          if (op.face) {
            fill = stroke = op.table[f.q[op.r]];
            const w = f.w[op.r];
            if (w >= 0) width = w;
          }
        } else if (kind === 2) {
          alpha *= f.o[op.r];
          if (alpha <= 0.001) continue;
        } else path = op.path;
        opCount++;
        if (alpha !== curAlpha) {
          ctx.globalAlpha = alpha;
          curAlpha = alpha;
        }
        const M = op.chain !== null ? chainMatrix(op.chain, f) : null;
        if (kind === 3) {
          const T = cpMul(base, M);
          ctx.setTransform(T[0], T[1], T[2], T[3], T[4], T[5]);
          curTransformed = true;
          ctx.font = op.font;
          ctx.textAlign = op.align;
          ctx.textBaseline = op.baseline;
          if ("letterSpacing" in ctx) ctx.letterSpacing = op.spacing;
          if (fill) {
            ctx.fillStyle = fill;
            curFill = fill;
            ctx.fillText(op.text, op.x, op.y);
          }
          continue;
        }
        let drawPath = path;
        const plain = M === null || (M[0] === 1 && M[1] === 0 && M[2] === 0 && M[3] === 1);
        if (kind === 2) {
          if (M === null) {
            if (curTransformed) {
              ctx.setTransform(b0, 0, 0, b3, bx, by);
              curTransformed = false;
            }
          } else {
            ctx.setTransform(b0 * M[0], b3 * M[1], b0 * M[2], b3 * M[3], b0 * M[4] + bx, b3 * M[5] + by);
            curTransformed = true;
          }
          ctx.beginPath();
          ctx.arc(f.cx[op.r], f.cy[op.r], op.size, 0, 6.283185307179586);
          drawPath = null;
        } else if (M === null) {
          if (curTransformed) {
            ctx.setTransform(b0, 0, 0, b3, bx, by);
            curTransformed = false;
          }
          if (token !== null) cpTrace(ctx, token, f.arena);
        } else if (plain || !(stroke && op.nonScaling)) {
          ctx.setTransform(b0 * M[0], b3 * M[1], b0 * M[2], b3 * M[3], b0 * M[4] + bx, b3 * M[5] + by);
          curTransformed = true;
          if (token !== null) cpTrace(ctx, token, f.arena);
        } else {
          if (curTransformed) {
            ctx.setTransform(b0, 0, 0, b3, bx, by);
            curTransformed = false;
          }
          drawPath = new Path2D();
          drawPath.addPath(path, new DOMMatrix(M));
        }
        if (fill !== null) {
          if (fill !== curFill) {
            ctx.fillStyle = fill;
            curFill = fill;
          }
          if (drawPath !== null) op.evenodd ? ctx.fill(drawPath, "evenodd") : ctx.fill(drawPath);
          else op.evenodd ? ctx.fill("evenodd") : ctx.fill();
        }
        if (stroke !== null && width > 0) {
          if (stroke !== curStroke) {
            ctx.strokeStyle = stroke;
            curStroke = stroke;
          }
          const scaleOf = M === null || plain ? 1 : Math.sqrt(Math.abs(M[0] * M[3] - M[1] * M[2]));
          const lw = op.nonScaling ? (width * unitLine) / (drawPath === path ? scaleOf : 1) : width;
          if (lw !== curWidth) {
            ctx.lineWidth = lw;
            curWidth = lw;
          }
          if (op.cap !== curCap) {
            ctx.lineCap = op.cap;
            curCap = op.cap;
          }
          if (op.join !== curJoin) {
            ctx.lineJoin = op.join;
            curJoin = op.join;
          }
          if (op.miter !== curMiter) {
            ctx.miterLimit = op.miter;
            curMiter = op.miter;
          }
          if (op.dash !== null || dashed) {
            ctx.setLineDash(op.dash !== null ? op.dash.map((v) => v * (op.nonScaling ? unitLine : 1)) : []);
            ctx.lineDashOffset = op.dashOffset;
            dashed = op.dash !== null;
          }
          if (drawPath !== null) ctx.stroke(drawPath);
          else ctx.stroke();
        }
      }
        };
    if (below) {
      for (let at = 0; at < order.length; at++) if (below(order[at])) drawRun(order[at]);
      if (mask) {
        mask(ctx, rect);
        ctx.setTransform(b0, 0, 0, b3, b4, b5);
        curTransformed = false;
        curBx = b4;
        curBy = b5;
        curFill = null;
        curStroke = null;
        curWidth = -1;
        curAlpha = -1;
        ctx.globalAlpha = 1;
      }
      for (let at = 0; at < order.length; at++) if (!below(order[at])) drawRun(order[at]);
    } else for (let at = 0; at < order.length; at++) drawRun(order[at]);
    stats.ops += opCount;
    stats.paths += opCount;
    if (dashed) ctx.setLineDash([]);
    if (rect) ctx.restore();
  };
  let numeric = Boolean(controller.numeric);
  let incremental = false;
  let forceFull = true;
  const paint = () => {
    const t = performance.now();
    stats.ops = 0;
    stats.paths = 0;
    stats.parsed = 0;
    stats.touched = 0;
    stats.rects = 0;
    stats.full = 0;
    const frames = controller.frames();
    const emitted = controller.emitted ? controller.emitted() : null;
    const all = runs.length;
    const full = !incremental || forceFull || !painted || !emitted || emitted.length > all * 0.25;
    if (full) {
      for (const layer of layers) paintLayer(layer, frames);
      stats.full = 1;
      keepBoxes(frames, runs.map((run) => run.index));
    } else if (emitted.length || effectRuns.length) {
      const dirty = effectRuns.length ? emitted.concat(effectRuns) : emitted;
      const rects = rectsOf(frames, dirty);
      stats.rects = rects.length;
      for (const rect of rects) for (const layer of layers) paintLayer(layer, frames, rect);
      keepBoxes(frames, dirty);
    }
    painted = true;
    forceFull = false;
    stats.ms = performance.now() - t;
    return stats;
  };
  resize();
  for (const layer of layers) {
    if (keepSvg) layer.svg.style.visibility = "hidden";
    else layer.svg.remove();
  }
  let watching = null;
  if (typeof ResizeObserver === "function") {
    watching = new ResizeObserver(() => {
      resize();
      paint();
    });
    watching.observe(stage);
  }
  const dprQuery = () => {
    const mq = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
    mq.addEventListener("change", () => {
      resize();
      paint();
      dprQuery();
    }, { once: true });
  };
  dprQuery();
  stage.setAttribute("data-painter", "canvas");
  return {
    paint,
    base: () => base.slice(),
    stats: () => ({ ...stats }),
    compileMs,
    exportSvg({ gl = true, background = true } = {}) {
      controller.snapshot();
      const out = [];
      for (const child of stage.children) {
        const tag = child.localName;
        if (tag === "svg") {
          if (child.style.visibility === "hidden") continue;
          out.push(`<g class="${child.getAttribute("class") || ""}">${child.innerHTML}</g>`);
        } else if (tag === "canvas") {
          const layer = layers.find((l) => l.canvas === child);
          if (layer) out.push(`<g class="${layer.svg.getAttribute("class") || ""}">${layer.svg.innerHTML}</g>`);
          else if (gl && child.width > 1 && getComputedStyle(child).display !== "none") out.push(`<image href="${child.toDataURL("image/png")}" x="0" y="0" width="${W}" height="${H}" preserveAspectRatio="none"/>`);
        }
      }
      const css = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n");
      const holder = stage.closest("[data-theme]");
      const theme = holder ? holder.getAttribute("data-theme") : "dark";
      const card = getComputedStyle(stage.closest(".iso-plate") || stage).getPropertyValue("--anatomy-card").trim() || "#fff";
      return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><style>${css.replace(/<\/style/gi, "")}</style><g class="iso" data-theme="${theme}"><g class="iso-plate" style="padding:0;box-shadow:none">${background ? `<rect width="${W}" height="${H}" fill="${card}"/>` : ""}<g class="${stage.getAttribute("class") || ""}">${out.join("")}</g></g></g></svg>`;
    },
    set incremental(on) {
      incremental = Boolean(on);
      forceFull = true;
    },
    get incremental() {
      return incremental;
    },
    flush() {
      for (const layer of layers) layer.ctx.getImageData(0, 0, 1, 1);
    },
    get numeric() {
      return numeric;
    },
    unsupported: [...unsupported],
    layers: layers.map((l) => l.name),
    ops: programs.reduce((sum, ops) => sum + (ops ? ops.length : 0), 0),
    zoom(z, cx = W / 2, cy = H / 2) {
      view.zoom = z;
      view.cx = cx;
      view.cy = cy;
      resize();
      return paint();
    },
    destroy() {
      if (watching) watching.disconnect();
    },
  };
}

export const CANVAS2D = { mount: cpMount };
```

## kit/gl-shared.mjs

```js
export const GLSL_ISO = `precision highp float;
uniform vec2 uCanvas;
uniform vec2 uView;
uniform vec2 uOrigin;
uniform float uK;
uniform vec4 uCam;
uniform float uTime;
vec2 viewOf(vec2 frag){return vec2(frag.x,uCanvas.y-frag.y)*(uView.x/uCanvas.x);}
vec2 project(vec3 p){return uOrigin+uK*vec2(p.x*uCam.x-p.y*uCam.y,(p.x*uCam.y+p.y*uCam.x)*uCam.z-p.z*uCam.w);}
vec3 towardViewer(){return vec3(uCam.y*uCam.w,uCam.x*uCam.w,uCam.z);}
vec3 rayOf(vec2 vb){vec2 q=(vb-uOrigin)/uK;return vec3(q.x*uCam.x,-q.x*uCam.y,-q.y/uCam.w);}
float pixelOf(){return (uView.x/uCanvas.x)/uK;}
vec3 lightDir(){return normalize(vec3(-uCam.x,uCam.y,1.2));}
vec3 onFloor(vec2 vb,float h){vec2 q=(vb-uOrigin)/uK;float b=(q.y+h*uCam.w)/uCam.z;return vec3(q.x*uCam.x+b*uCam.y,-q.x*uCam.y+b*uCam.x,h);}
vec3 onWallY(vec2 vb,float y){vec2 q=(vb-uOrigin)/uK;float x=(q.x+y*uCam.y)/uCam.x;return vec3(x,y,((x*uCam.y+y*uCam.x)*uCam.z-q.y)/uCam.w);}
vec3 onWallX(vec2 vb,float x){vec2 q=(vb-uOrigin)/uK;float y=(x*uCam.x-q.x)/uCam.y;return vec3(x,y,((x*uCam.y+y*uCam.x)*uCam.z-q.y)/uCam.w);}
float hash12(vec2 p){vec3 q=fract(vec3(p.xyx)*.1031);q+=dot(q,q.yzx+33.33);return fract((q.x+q.y)*q.z);}
float hash13(vec3 p){p=fract(p*.1031);p+=dot(p,p.zyx+31.32);return fract((p.x+p.y)*p.z);}
float noise2(vec2 p){vec2 i=floor(p);vec2 f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash12(i),hash12(i+vec2(1.,0.)),f.x),mix(hash12(i+vec2(0.,1.)),hash12(i+vec2(1.,1.)),f.x),f.y);}
float noise3(vec3 p){vec3 i=floor(p);vec3 f=fract(p);f=f*f*(3.-2.*f);
  float a=mix(mix(hash13(i),hash13(i+vec3(1.,0.,0.)),f.x),mix(hash13(i+vec3(0.,1.,0.)),hash13(i+vec3(1.,1.,0.)),f.x),f.y);
  float b=mix(mix(hash13(i+vec3(0.,0.,1.)),hash13(i+vec3(1.,0.,1.)),f.x),mix(hash13(i+vec3(0.,1.,1.)),hash13(i+vec3(1.,1.,1.)),f.x),f.y);
  return mix(a,b,f.z);}
float fbm2(vec2 p){float s=0.;float a=.5;for(int i=0;i<4;i++){s+=a*noise2(p);p=p*2.07+vec2(17.1,9.2);a*=.5;}return s/.9375;}
float fbm3(vec3 p){float s=0.;float a=.5;for(int i=0;i<4;i++){s+=a*noise3(p);p=p*2.07+vec3(17.1,9.2,4.7);a*=.5;}return s/.9375;}
float window(float x,float a,float b,float soft){return smoothstep(a-soft,a+soft,x)*(1.-smoothstep(b-soft,b+soft,x));}
vec3 tonemap(vec3 c,float exposure){return 1.-exp(-c*exposure);}
`;

export const GLSL_TURN = `uniform vec3 uGroupX;
uniform vec3 uGroupY;
uniform vec3 uGroupZ;
uniform vec3 uGroupT;
uniform vec3 uCoverEdge[60];
uniform float uCoverCount[6];
vec3 toGroup(vec3 p){vec3 q=p-uGroupT;return vec3(dot(q,uGroupX),dot(q,uGroupY),dot(q,uGroupZ));}
vec3 toWorld(vec3 g){return uGroupT+g.x*uGroupX+g.y*uGroupY+g.z*uGroupZ;}
vec3 dirToGroup(vec3 d){return vec3(dot(d,uGroupX),dot(d,uGroupY),dot(d,uGroupZ));}
vec3 dirToWorld(vec3 d){return d.x*uGroupX+d.y*uGroupY+d.z*uGroupZ;}
float coverOf(vec2 vb,float soft){float c=0.;for(int p=0;p<6;p++){float n=uCoverCount[p];float inside=-1e4;for(int e=0;e<10;e++){if(float(e)>=n)break;vec3 h=uCoverEdge[p*10+e];inside=max(inside,h.x*vb.x+h.y*vb.y+h.z);}if(n>0.)c=max(c,1.-smoothstep(-soft,soft,inside));}return c;}
`;

export function glslPose(name, prefix = `u${name}`) {
  return `uniform vec3 ${prefix}X;uniform vec3 ${prefix}Y;uniform vec3 ${prefix}Z;uniform vec3 ${prefix}T;
vec3 to${name}(vec3 p){vec3 q=p-${prefix}T;return vec3(dot(q,${prefix}X),dot(q,${prefix}Y),dot(q,${prefix}Z));}
vec3 from${name}(vec3 g){return ${prefix}T+g.x*${prefix}X+g.y*${prefix}Y+g.z*${prefix}Z;}
vec3 dirTo${name}(vec3 d){return vec3(dot(d,${prefix}X),dot(d,${prefix}Y),dot(d,${prefix}Z));}
vec3 dirFrom${name}(vec3 d){return d.x*${prefix}X+d.y*${prefix}Y+d.z*${prefix}Z;}
`;
}

export function glCamera(projection) {
  const a = ((projection.azimuth ?? 45) * Math.PI) / 180;
  const e = ((projection.elevation ?? 30) * Math.PI) / 180;
  return {
    origin: [projection.origin[0], projection.origin[1]],
    k: projection.scale * Math.sqrt(1.6),
    cam: [Math.sin(a), Math.cos(a), Math.sin(e), Math.cos(e)],
  };
}

const VERTEX = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";

export function glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, untransformed = false, onError = (message) => console.error(message) }) {
  const gl = canvas.getContext("webgl", { premultipliedAlpha: true, alpha: true, antialias: false, preserveDrawingBuffer: false });
  if (!gl) return null;
  const compile = (type, source) => {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
    onError(gl.getShaderInfoLog(shader));
    return null;
  };
  const vs = compile(gl.VERTEX_SHADER, VERTEX);
  const enabled = extensions.filter((name) => gl.getExtension(name.replace(/^GL_/, "")));
  const fs = compile(gl.FRAGMENT_SHADER, enabled.map((name) => `#extension ${name} : enable\n`).join("") + GLSL_ISO + fragment);
  if (!vs || !fs) return null;
  const program = gl.createProgram();
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    onError(gl.getProgramInfoLog(program));
    return null;
  }
  gl.useProgram(program);
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, "p");
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
  const where = {};
  for (const name of ["uCanvas", "uView", "uOrigin", "uK", "uCam", "uTime", ...uniforms, ...Object.keys(fixed)]) where[name] = gl.getUniformLocation(program, name);
  const types = {};
  const active = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
  for (let index = 0; index < active; index++) {
    const info = gl.getActiveUniform(program, index);
    if (info) types[info.name.replace(/\[0\]$/, "")] = info.type;
  }
  let painted = false;
  const fit = () => {
    const box = untransformed ? { width: canvas.clientWidth, height: canvas.clientHeight } : canvas.getBoundingClientRect();
    const ratio = Math.min(maxRatio, window.devicePixelRatio || 1);
    const width = Math.max(1, Math.round(box.width * ratio));
    const height = Math.max(1, Math.round(box.height * ratio));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
  };
  const set = (name, value) => {
    const at = where[name];
    if (at === undefined || at === null) return;
    if (typeof value === "number") gl.uniform1f(at, value);
    else if (value.length > 4) {
      const data = value instanceof Float32Array ? value : new Float32Array(value);
      const type = types[name];
      if (type === gl.FLOAT_VEC2) gl.uniform2fv(at, data);
      else if (type === gl.FLOAT_VEC3) gl.uniform3fv(at, data);
      else if (type === gl.FLOAT_VEC4) gl.uniform4fv(at, data);
      else gl.uniform1fv(at, data);
    }
    else if (value.length === 1) gl.uniform1f(at, value[0]);
    else if (value.length === 2) gl.uniform2f(at, value[0], value[1]);
    else if (value.length === 3) gl.uniform3f(at, value[0], value[1], value[2]);
    else if (value.length === 4) gl.uniform4f(at, value[0], value[1], value[2], value[3]);
  };
  for (const [name, value] of Object.entries(fixed)) set(name, value);
  return {
    gl,
    extensions: enabled,
    set(values = {}) {
      for (const [name, value] of Object.entries(values)) set(name, value);
    },
    draw(values = {}, time = 0) {
      fit();
      gl.viewport(0, 0, canvas.width, canvas.height);
      set("uCanvas", [canvas.width, canvas.height]);
      set("uView", view);
      set("uOrigin", camera.origin);
      set("uK", camera.k);
      set("uCam", camera.cam);
      set("uTime", time);
      for (const [name, value] of Object.entries(values)) set(name, value);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      painted = true;
    },
    clear() {
      if (!painted) return;
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      painted = false;
    },
  };
}

export const smooth = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

export const burst = (age, rise, fall) => (age > 0 ? smooth(0, rise, age) * Math.exp(-Math.max(0, age - rise) / fall) : 0);

export function settle(state, target, dt, seconds, calm = false) {
  if (calm) {
    state.x = target;
    state.v = 0;
    return false;
  }
  const w = 1 / seconds;
  for (let index = 0; index < 4; index++) {
    const h = dt / 4;
    state.v += (w * w * (target - state.x) - 2 * w * state.v) * h;
    state.x += state.v * h;
  }
  if (Math.abs(target - state.x) < 1e-4 && Math.abs(state.v) < 1e-4) {
    state.x = target;
    state.v = 0;
    return false;
  }
  return true;
}
```

## kit/gl.mjs

```js
export const GLSL_ISO = `precision highp float;
uniform vec2 uCanvas;
uniform vec2 uView;
uniform vec2 uOrigin;
uniform float uK;
uniform vec4 uCam;
uniform float uTime;
vec2 viewOf(vec2 frag){return vec2(frag.x,uCanvas.y-frag.y)*(uView.x/uCanvas.x);}
vec2 project(vec3 p){return uOrigin+uK*vec2(p.x*uCam.x-p.y*uCam.y,(p.x*uCam.y+p.y*uCam.x)*uCam.z-p.z*uCam.w);}
vec3 towardViewer(){return vec3(uCam.y*uCam.w,uCam.x*uCam.w,uCam.z);}
vec3 rayOf(vec2 vb){vec2 q=(vb-uOrigin)/uK;return vec3(q.x*uCam.x,-q.x*uCam.y,-q.y/uCam.w);}
float pixelOf(){return (uView.x/uCanvas.x)/uK;}
vec3 lightDir(){return normalize(vec3(-uCam.x,uCam.y,1.2));}
vec3 onFloor(vec2 vb,float h){vec2 q=(vb-uOrigin)/uK;float b=(q.y+h*uCam.w)/uCam.z;return vec3(q.x*uCam.x+b*uCam.y,-q.x*uCam.y+b*uCam.x,h);}
vec3 onWallY(vec2 vb,float y){vec2 q=(vb-uOrigin)/uK;float x=(q.x+y*uCam.y)/uCam.x;return vec3(x,y,((x*uCam.y+y*uCam.x)*uCam.z-q.y)/uCam.w);}
vec3 onWallX(vec2 vb,float x){vec2 q=(vb-uOrigin)/uK;float y=(x*uCam.x-q.x)/uCam.y;return vec3(x,y,((x*uCam.y+y*uCam.x)*uCam.z-q.y)/uCam.w);}
float hash12(vec2 p){vec3 q=fract(vec3(p.xyx)*.1031);q+=dot(q,q.yzx+33.33);return fract((q.x+q.y)*q.z);}
float hash13(vec3 p){p=fract(p*.1031);p+=dot(p,p.zyx+31.32);return fract((p.x+p.y)*p.z);}
float noise2(vec2 p){vec2 i=floor(p);vec2 f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash12(i),hash12(i+vec2(1.,0.)),f.x),mix(hash12(i+vec2(0.,1.)),hash12(i+vec2(1.,1.)),f.x),f.y);}
float noise3(vec3 p){vec3 i=floor(p);vec3 f=fract(p);f=f*f*(3.-2.*f);
  float a=mix(mix(hash13(i),hash13(i+vec3(1.,0.,0.)),f.x),mix(hash13(i+vec3(0.,1.,0.)),hash13(i+vec3(1.,1.,0.)),f.x),f.y);
  float b=mix(mix(hash13(i+vec3(0.,0.,1.)),hash13(i+vec3(1.,0.,1.)),f.x),mix(hash13(i+vec3(0.,1.,1.)),hash13(i+vec3(1.,1.,1.)),f.x),f.y);
  return mix(a,b,f.z);}
float fbm2(vec2 p){float s=0.;float a=.5;for(int i=0;i<4;i++){s+=a*noise2(p);p=p*2.07+vec2(17.1,9.2);a*=.5;}return s/.9375;}
float fbm3(vec3 p){float s=0.;float a=.5;for(int i=0;i<4;i++){s+=a*noise3(p);p=p*2.07+vec3(17.1,9.2,4.7);a*=.5;}return s/.9375;}
float window(float x,float a,float b,float soft){return smoothstep(a-soft,a+soft,x)*(1.-smoothstep(b-soft,b+soft,x));}
vec3 tonemap(vec3 c,float exposure){return 1.-exp(-c*exposure);}
`;

export const GLSL_TURN = `uniform vec3 uGroupX;
uniform vec3 uGroupY;
uniform vec3 uGroupZ;
uniform vec3 uGroupT;
uniform vec3 uCoverEdge[60];
uniform float uCoverCount[6];
vec3 toGroup(vec3 p){vec3 q=p-uGroupT;return vec3(dot(q,uGroupX),dot(q,uGroupY),dot(q,uGroupZ));}
vec3 toWorld(vec3 g){return uGroupT+g.x*uGroupX+g.y*uGroupY+g.z*uGroupZ;}
vec3 dirToGroup(vec3 d){return vec3(dot(d,uGroupX),dot(d,uGroupY),dot(d,uGroupZ));}
vec3 dirToWorld(vec3 d){return d.x*uGroupX+d.y*uGroupY+d.z*uGroupZ;}
float coverOf(vec2 vb,float soft){float c=0.;for(int p=0;p<6;p++){float n=uCoverCount[p];float inside=-1e4;for(int e=0;e<10;e++){if(float(e)>=n)break;vec3 h=uCoverEdge[p*10+e];inside=max(inside,h.x*vb.x+h.y*vb.y+h.z);}if(n>0.)c=max(c,1.-smoothstep(-soft,soft,inside));}return c;}
`;

export function glslPose(name, prefix = `u${name}`) {
  return `uniform vec3 ${prefix}X;uniform vec3 ${prefix}Y;uniform vec3 ${prefix}Z;uniform vec3 ${prefix}T;
vec3 to${name}(vec3 p){vec3 q=p-${prefix}T;return vec3(dot(q,${prefix}X),dot(q,${prefix}Y),dot(q,${prefix}Z));}
vec3 from${name}(vec3 g){return ${prefix}T+g.x*${prefix}X+g.y*${prefix}Y+g.z*${prefix}Z;}
vec3 dirTo${name}(vec3 d){return vec3(dot(d,${prefix}X),dot(d,${prefix}Y),dot(d,${prefix}Z));}
vec3 dirFrom${name}(vec3 d){return d.x*${prefix}X+d.y*${prefix}Y+d.z*${prefix}Z;}
`;
}

export function glCamera(projection) {
  const a = ((projection.azimuth ?? 45) * Math.PI) / 180;
  const e = ((projection.elevation ?? 30) * Math.PI) / 180;
  return {
    origin: [projection.origin[0], projection.origin[1]],
    k: projection.scale * Math.sqrt(1.6),
    cam: [Math.sin(a), Math.cos(a), Math.sin(e), Math.cos(e)],
  };
}

const VERTEX = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";

export function glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, onError = (message) => console.error(message) }) {
  const gl = canvas.getContext("webgl", { premultipliedAlpha: true, alpha: true, antialias: false, preserveDrawingBuffer: false });
  if (!gl) return null;
  const compile = (type, source) => {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
    onError(gl.getShaderInfoLog(shader));
    return null;
  };
  const vs = compile(gl.VERTEX_SHADER, VERTEX);
  const enabled = extensions.filter((name) => gl.getExtension(name.replace(/^GL_/, "")));
  const fs = compile(gl.FRAGMENT_SHADER, enabled.map((name) => `#extension ${name} : enable\n`).join("") + GLSL_ISO + fragment);
  if (!vs || !fs) return null;
  const program = gl.createProgram();
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    onError(gl.getProgramInfoLog(program));
    return null;
  }
  gl.useProgram(program);
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, "p");
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
  const where = {};
  for (const name of ["uCanvas", "uView", "uOrigin", "uK", "uCam", "uTime", ...uniforms, ...Object.keys(fixed)]) where[name] = gl.getUniformLocation(program, name);
  const types = {};
  const active = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
  for (let index = 0; index < active; index++) {
    const info = gl.getActiveUniform(program, index);
    if (info) types[info.name.replace(/\[0\]$/, "")] = info.type;
  }
  let painted = false;
  const fit = () => {
    const box = canvas.getBoundingClientRect();
    const ratio = Math.min(maxRatio, window.devicePixelRatio || 1);
    const width = Math.max(1, Math.round(box.width * ratio));
    const height = Math.max(1, Math.round(box.height * ratio));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
  };
  const set = (name, value) => {
    const at = where[name];
    if (at === undefined || at === null) return;
    if (typeof value === "number") gl.uniform1f(at, value);
    else if (value.length > 4) {
      const data = value instanceof Float32Array ? value : new Float32Array(value);
      const type = types[name];
      if (type === gl.FLOAT_VEC2) gl.uniform2fv(at, data);
      else if (type === gl.FLOAT_VEC3) gl.uniform3fv(at, data);
      else if (type === gl.FLOAT_VEC4) gl.uniform4fv(at, data);
      else gl.uniform1fv(at, data);
    }
    else if (value.length === 1) gl.uniform1f(at, value[0]);
    else if (value.length === 2) gl.uniform2f(at, value[0], value[1]);
    else if (value.length === 3) gl.uniform3f(at, value[0], value[1], value[2]);
    else if (value.length === 4) gl.uniform4f(at, value[0], value[1], value[2], value[3]);
  };
  for (const [name, value] of Object.entries(fixed)) set(name, value);
  return {
    gl,
    extensions: enabled,
    set(values = {}) {
      for (const [name, value] of Object.entries(values)) set(name, value);
    },
    draw(values = {}, time = 0) {
      fit();
      gl.viewport(0, 0, canvas.width, canvas.height);
      set("uCanvas", [canvas.width, canvas.height]);
      set("uView", view);
      set("uOrigin", camera.origin);
      set("uK", camera.k);
      set("uCam", camera.cam);
      set("uTime", time);
      for (const [name, value] of Object.entries(values)) set(name, value);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      painted = true;
    },
    clear() {
      if (!painted) return;
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      painted = false;
    },
  };
}

export const smooth = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

export const burst = (age, rise, fall) => (age > 0 ? smooth(0, rise, age) * Math.exp(-Math.max(0, age - rise) / fall) : 0);

export function settle(state, target, dt, seconds, calm = false) {
  if (calm) {
    state.x = target;
    state.v = 0;
    return false;
  }
  const w = 1 / seconds;
  for (let index = 0; index < 4; index++) {
    const h = dt / 4;
    state.v += (w * w * (target - state.x) - 2 * w * state.v) * h;
    state.x += state.v * h;
  }
  if (Math.abs(target - state.x) < 1e-4 && Math.abs(state.v) < 1e-4) {
    state.x = target;
    state.v = 0;
    return false;
  }
  return true;
}
```

## kit/iso-kit.mjs

```js
export const AZIMUTH = 45;
export const ELEVATION = 30;
export const SHADES = 4;
const RADIANS = Math.PI / 180;
const UNIT = Math.sqrt(1.6);
export function cameraOf({ scale, azimuth = AZIMUTH, elevation = ELEVATION }) {
    const a = azimuth * RADIANS;
    const e = elevation * RADIANS;
    return { sinA: Math.sin(a), cosA: Math.cos(a), sinE: Math.sin(e), cosE: Math.cos(e), k: scale * UNIT };
}
export function iso([x, y, z], projection) {
    const { origin } = projection;
    const { sinA, cosA, sinE, cosE, k } = cameraOf(projection);
    return [origin[0] + (x * sinA - y * cosA) * k, origin[1] + ((x * cosA + y * sinA) * sinE - z * cosE) * k];
}
export function towardViewer(projection) {
    const { sinA, cosA } = cameraOf(projection);
    return [cosA, sinA];
}
export function depthOf([x, y, z], projection) {
    const { sinA, cosA, sinE, cosE } = cameraOf(projection);
    return (x * cosA + y * sinA) * cosE + z * sinE;
}
const tenth = (value) => {
    const text = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, "");
    return text === "-0" ? "0" : text;
};
export function pathOf(points, closed = false) {
    if (!points.length)
        return "";
    return points.map(([x, y], at) => `${at ? "L" : "M"}${tenth(x)} ${tenth(y)}`).join("") + (closed ? "Z" : "");
}
export const joinPaths = (paths) => paths.filter(Boolean).join("");
export function roundedPlan({ x, y, w, d, r }, steps = 8) {
    const radius = Math.max(0, Math.min(r, w / 2, d / 2));
    const corners = [
        [x + w - radius, y + radius, -90],
        [x + w - radius, y + d - radius, 0],
        [x + radius, y + d - radius, 90],
        [x + radius, y + radius, 180],
    ];
    const out = [];
    for (const [cx, cy, start] of corners) {
        const count = radius > 0 ? steps : 0;
        for (let step = 0; step <= count; step++) {
            const angle = (start + (count ? (90 * step) / count : 45)) * RADIANS;
            out.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]);
        }
    }
    return out;
}
function signedArea(points) {
    let area = 0;
    for (let index = 0; index < points.length; index++) {
        const [ax, ay] = points[index];
        const [bx, by] = points[(index + 1) % points.length];
        area += ax * by - bx * ay;
    }
    return area / 2;
}
const oriented = (points) => (signedArea(points) < 0 ? [...points].reverse() : points);
function convexHull(points) {
    const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
    if (sorted.length < 3)
        return sorted;
    const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
    const lower = [];
    for (const point of sorted) {
        while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0)
            lower.pop();
        lower.push(point);
    }
    const upper = [];
    for (let index = sorted.length - 1; index >= 0; index--) {
        const point = sorted[index];
        while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0)
            upper.pop();
        upper.push(point);
    }
    return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
function runsOf(flags, want) {
    const count = flags.length;
    const runs = [];
    const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
    if (start < 0)
        return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
    let run = null;
    for (let step = 0; step < count; step++) {
        const index = (start + step) % count;
        if (flags[index] === want) {
            if (!run)
                run = [index];
            run.push((index + 1) % count);
        }
        else if (run) {
            runs.push(run);
            run = null;
        }
    }
    if (run)
        runs.push(run);
    return runs;
}
export function extrude(ring, z, height, projection, options = {}) {
    const convex = options.convex ?? true;
    const count = ring.length;
    const counter = signedArea(ring) > 0;
    const [vx, vy] = towardViewer(projection);
    const { sinA, cosA } = cameraOf(projection);
    const normals = ring.map(([ax, ay], index) => {
        const [bx, by] = ring[(index + 1) % count];
        const dx = bx - ax;
        const dy = by - ay;
        const length = Math.hypot(dx, dy) || 1;
        return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]);
    });
    const facing = normals.map(([nx, ny]) => nx * vx + ny * vy > 1e-9);
    const top = ring.map(([x, y]) => iso([x, y, z + height], projection));
    const bottom = ring.map(([x, y]) => iso([x, y, z], projection));
    const outline = [];
    const crease = [];
    for (const run of runsOf(facing, false))
        outline.push(pathOf(run.map((index) => top[index])));
    for (const run of runsOf(facing, true)) {
        crease.push(pathOf(run.map((index) => top[index])));
        if (height > 0)
            outline.push(pathOf(run.map((index) => bottom[index])));
    }
    if (height > 0) {
        for (let index = 0; index < count; index++) {
            if (facing[(index + count - 1) % count] !== facing[index])
                outline.push(pathOf([top[index], bottom[index]]));
        }
    }
    let fill;
    if (convex) {
        fill = pathOf(convexHull([...top, ...bottom]), true);
    }
    else {
        const parts = [pathOf(oriented(top), true), pathOf(oriented(bottom), true)];
        for (let index = 0; index < count; index++) {
            if (!facing[index])
                continue;
            const next = (index + 1) % count;
            parts.push(pathOf(oriented([top[index], top[next], bottom[next], bottom[index]]), true));
        }
        fill = parts.join("");
    }
    const buckets = Array.from({ length: SHADES }, () => []);
    if (height > 0) {
        const [lx, ly] = [-sinA, cosA];
        for (let index = 0; index < count; index++) {
            if (!facing[index])
                continue;
            const [nx, ny] = normals[index];
            const lit = (nx * lx + ny * ly + 1) / 2;
            const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES)));
            const next = (index + 1) % count;
            buckets[shade].push(pathOf(oriented([top[index], top[next], bottom[next], bottom[index]]), true));
        }
    }
    let bevel;
    if (options.bevel && options.bevel > 0) {
        const by = options.bevel;
        const inset = ring.map(([x, y], index) => {
            const [ax, ay] = normals[(index + count - 1) % count];
            const [bx, by2] = normals[index];
            const mx = ax + bx;
            const my = ay + by2;
            const length = Math.hypot(mx, my) || 1;
            return [x - (mx / length) * by, y - (my / length) * by];
        });
        const insetTop = inset.map(([x, y]) => iso([x, y, z + height], projection));
        bevel = runsOf(facing, true)
            .map((run) => pathOf([top[run[0]], ...run.slice(1, -1).map((index) => insetTop[index]), top[run[run.length - 1]]]))
            .join("");
    }
    return {
        fill,
        outline: outline.join(""),
        crease: crease.join(""),
        top: pathOf(oriented(top), true),
        shades: buckets.map((paths) => paths.join("")),
        bevel,
    };
}
export const ringOf = (plan, steps = 8) => roundedPlan(plan, steps);
export function slabOf(plan, z, height, projection, steps = 8, bevel = 0) {
    return extrude(ringOf(plan, steps), z, height, projection, { convex: true, bevel });
}
export function circleRing(cx, cy, radius, steps = 40) {
    return Array.from({ length: steps }, (_, step) => {
        const angle = (step / steps) * Math.PI * 2;
        return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
    });
}
export function cylinder(cx, cy, radius, z, height, projection, steps = 32, bevel = 0) {
    return extrude(circleRing(cx, cy, radius, steps), z, height, projection, { convex: true, bevel });
}
export const insetPlan = ({ x, y, w, d, r }, by) => ({ x: x + by, y: y + by, w: w - by * 2, d: d - by * 2, r: Math.max(0, r - by) });
export function onTop(points, z, projection, closed = false) {
    return pathOf(points.map(([x, y]) => iso([x, y, z], projection)), closed);
}
export const planOutline = (plan, z, projection, steps = 8) => onTop(ringOf(plan, steps), z, projection, true);
export const lineOnTop = (from, to, z, projection) => onTop([from, to], z, projection);
export const segment = (from, to, projection) => pathOf([iso(from, projection), iso(to, projection)]);
export const rise = (at, from, to, projection) => segment([at[0], at[1], from], [at[0], at[1], to], projection);
export const ring = (cx, cy, radius, z, projection, steps = 40) => onTop(circleRing(cx, cy, radius, steps), z, projection, true);
export const pointsAt = (points, projection) => points.map((point) => iso(point, projection));
export function dotGrid(x, y, columns, rows, gap, z, projection) {
    const dots = [];
    for (let row = 0; row < rows; row++) {
        for (let column = 0; column < columns; column++)
            dots.push(iso([x + column * gap, y + row * gap, z], projection));
    }
    return dots;
}
export function corners({ x, y, w, d }, inset = 0) {
    return [
        [x + inset, y + inset],
        [x + w - inset, y + inset],
        [x + inset, y + d - inset],
        [x + w - inset, y + d - inset],
    ];
}
export function boxCorners({ x, y, w, d }, z0, z1) {
    const out = [];
    for (const px of [x, x + w])
        for (const py of [y, y + d])
            for (const z of [z0, z1])
                out.push([px, py, z]);
    return out;
}
export function knurl(cx, cy, radius, z0, z1, count, projection, inset = 0.4) {
    const [vx, vy] = towardViewer(projection);
    const lines = [];
    for (let index = 0; index < count; index++) {
        const angle = (index / count) * Math.PI * 2;
        const nx = Math.cos(angle);
        const ny = Math.sin(angle);
        if (nx * vx + ny * vy < 0.15)
            continue;
        lines.push(segment([cx + radius * nx, cy + radius * ny, z0 + inset], [cx + radius * nx, cy + radius * ny, z1 - inset], projection));
    }
    return lines.join("");
}
export function sideArc(cx, cy, radius, z, projection, steps = 24) {
    const [vx, vy] = towardViewer(projection);
    const facing = Math.atan2(vy, vx);
    const points = [];
    for (let step = 0; step <= steps; step++) {
        const angle = facing - Math.PI / 2 + (step / steps) * Math.PI;
        points.push(iso([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle), z], projection));
    }
    return pathOf(points);
}
export function ringSeam(ring, z, projection) {
    const count = ring.length;
    const counter = signedArea(ring) > 0;
    const [vx, vy] = towardViewer(projection);
    const facing = ring.map(([ax, ay], index) => {
        const [bx, by] = ring[(index + 1) % count];
        const dx = bx - ax;
        const dy = by - ay;
        const [nx, ny] = counter ? [dy, -dx] : [-dy, dx];
        return nx * vx + ny * vy > 1e-9;
    });
    return runsOf(facing, true).map((run) => pathOf(run.map((index) => iso([ring[index][0], ring[index][1], z], projection)))).join("");
}
export const sideSeam = (plan, z, projection, steps = 8) => ringSeam(ringOf(plan, steps), z, projection);
export function sideRing(center, radius, axis, projection, steps = 20) {
    const [px, py, pz] = center;
    const points = [];
    for (let step = 0; step < steps; step++) {
        const angle = (step / steps) * Math.PI * 2;
        const u = radius * Math.cos(angle);
        const v = radius * Math.sin(angle);
        points.push(iso(axis === "x" ? [px, py + u, pz + v] : [px + u, py, pz + v], projection));
    }
    return pathOf(points, true);
}
export function coil(from, to, center, radius, turns, axis, projection, perTurn = 16) {
    const count = Math.max(1, Math.round(turns * perTurn));
    const back = [];
    const front = [];
    let run = [];
    let near = null;
    for (let index = 0; index <= count; index++) {
        const share = index / count;
        const angle = share * turns * Math.PI * 2;
        const u = radius * Math.cos(angle);
        const v = radius * Math.sin(angle);
        const along = from + (to - from) * share;
        const point = axis === "x" ? [along, center[0] + u, center[1] + v] : [center[0] + u, along, center[1] + v];
        const normal = axis === "x" ? [0, Math.cos(angle), Math.sin(angle)] : [Math.cos(angle), 0, Math.sin(angle)];
        const facing = depthOf(normal, projection) > 0;
        const flat = iso(point, projection);
        if (near === null)
            near = facing;
        if (facing !== near) {
            run.push(flat);
            (near ? front : back).push(pathOf(run));
            run = [flat];
            near = facing;
            continue;
        }
        run.push(flat);
    }
    if (run.length > 1)
        (near ? front : back).push(pathOf(run));
    return { back: back.join(""), front: front.join("") };
}
export function topTicks(from, to, step, every, edge, z, lengths, projection, axis = "x") {
    const minor = [];
    const major = [];
    const first = Math.ceil(from / step) * step;
    for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
        const big = index % every === 0;
        const length = big ? lengths[1] : lengths[0];
        const a = axis === "x" ? [at, edge] : [edge, at];
        const b = axis === "x" ? [at, edge + length] : [edge + length, at];
        (big ? major : minor).push(lineOnTop(a, b, z, projection));
    }
    return { minor: minor.join(""), major: major.join("") };
}
export function sideTicks(from, to, step, every, face, top, lengths, projection, axis = "x") {
    const minor = [];
    const major = [];
    const first = Math.ceil(from / step) * step;
    for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
        const big = index % every === 0;
        const length = big ? lengths[1] : lengths[0];
        const a = axis === "x" ? [at, face, top] : [face, at, top];
        const b = axis === "x" ? [at, face, top - length] : [face, at, top - length];
        (big ? major : minor).push(segment(a, b, projection));
    }
    return { minor: minor.join(""), major: major.join("") };
}
export function radialTicks(cx, cy, radius, count, every, z, lengths, projection, from = 0, span = Math.PI * 2) {
    const minor = [];
    const major = [];
    const closed = Math.abs(span - Math.PI * 2) < 1e-9;
    const steps = closed ? count : count + 1;
    for (let index = 0; index < steps; index++) {
        const angle = from + (index / count) * span;
        const big = index % every === 0;
        const inner = radius - (big ? lengths[1] : lengths[0]);
        const sx = Math.sin(angle);
        const sy = -Math.cos(angle);
        (big ? major : minor).push(lineOnTop([cx + sx * inner, cy + sy * inner], [cx + sx * radius, cy + sy * radius], z, projection));
    }
    return { minor: minor.join(""), major: major.join("") };
}
export const haloOf = (plan, z, height, projection) => slabOf(plan, z, height, projection).fill;
export function axisVector(projection, [dx, dy, dz]) {
    const origin = iso([0, 0, 0], projection);
    const moved = iso([dx, dy, dz], projection);
    return [moved[0] - origin[0], moved[1] - origin[1]];
}
const fine = (value) => Math.round(value * 100) / 100;
export function translateAlong(projection, by) {
    const [x, y] = axisVector(projection, by);
    return `translate(${fine(x)} ${fine(y)})`;
}
const thousandth = (value) => Math.round(value * 1000) / 1000;
function axesAt(at, z, projection) {
    const origin = iso([at[0], at[1], z], projection);
    const along = (point) => {
        const [px, py] = iso(point, projection);
        return [px - origin[0], py - origin[1]];
    };
    return { origin, x: along([at[0] + 1, at[1], z]), y: along([at[0], at[1] + 1, z]), z: along([at[0], at[1], z + 1]) };
}
const matrixOf = (u, v, origin) => `matrix(${thousandth(u[0])} ${thousandth(u[1])} ${thousandth(v[0])} ${thousandth(v[1])} ${thousandth(origin[0])} ${thousandth(origin[1])})`;
export function holdStill(element, [dx, dy]) {
    const text = dx || dy ? `translate(${-dx} ${-dy})` : "";
    element.querySelectorAll("[data-hold]").forEach((node) => (text ? node.setAttribute("transform", text) : node.removeAttribute("transform")));
}
export function topMatrix(at, z, projection, along = "x") {
    const { origin, x, y } = axesAt(at, z, projection);
    return along === "x" ? matrixOf(x, y, origin) : matrixOf([-y[0], -y[1]], x, origin);
}
export function sideMatrix(at, z, projection, face) {
    const { origin, x, y, z: up } = axesAt(at, z, projection);
    const down = [-up[0], -up[1]];
    return face === "left" ? matrixOf(x, down, origin) : matrixOf([-y[0], -y[1]], down, origin);
}
export function screenRise(height, projection) {
    const { cosE, k } = cameraOf(projection);
    return height * cosE * k;
}
export function fitProjection(points, width, height, { pad = 24, headroom = 0, azimuth = AZIMUTH, elevation = ELEVATION } = {}) {
    const unit = { origin: [0, 0], scale: 1, azimuth, elevation };
    const flat = points.map((point) => iso(point, unit));
    const xs = flat.map(([x]) => x);
    const ys = flat.map(([, y]) => y);
    const left = Math.min(...xs);
    const right = Math.max(...xs);
    const up = Math.min(...ys);
    const down = Math.max(...ys);
    const scale = Math.min((width - pad * 2) / (right - left), (height - pad * 2 - headroom) / (down - up));
    return {
        origin: [width / 2 - ((left + right) / 2) * scale, headroom + (height - headroom) / 2 - ((up + down) / 2) * scale],
        scale,
        azimuth,
        elevation,
    };
}
const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const attrs = (pairs) => Object.entries(pairs)
    .filter(([, value]) => value !== undefined && value !== false)
    .map(([key, value]) => ` ${key}="${escape(String(value))}"`)
    .join("");
export function solidSvg(paths, { tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner = "" } = {}) {
    const parts = [];
    if (halo)
        parts.push(`<path class="iso-halo" d="${paths.fill}"/>`);
    parts.push(`<path class="iso-fill" d="${paths.fill}"/>`);
    if (!flat) {
        paths.shades.forEach((d, shade) => {
            if (d)
                parts.push(`<path class="iso-shade" data-shade="${shade}" d="${d}"/>`);
        });
        parts.push(`<path class="iso-top" d="${paths.top}"/>`);
    }
    if (crease !== "none" && paths.crease)
        parts.push(`<path class="iso-line iso-crease" data-tone="${crease}" d="${paths.crease}"/>`);
    if (crease !== "none" && paths.bevel)
        parts.push(`<path class="iso-line iso-bevel" data-tone="${crease}" d="${paths.bevel}"/>`);
    if (inner)
        parts.push(inner);
    parts.push(`<path class="iso-line iso-edge" data-tone="${tone}" d="${paths.outline}"/>`);
    return `<g${attrs({ class: className ? `iso-solid ${className}` : "iso-solid", "data-lit": lit ? "" : undefined, "data-seamless": paths.seamless ? "" : undefined })}>${parts.join("")}</g>`;
}
export function lineSvg(d, { tone = "lo", dotted = false, dashed = false, flow = false, className, free } = {}) {
    if (!d)
        return "";
    return `<path${attrs({
        class: className ? `iso-line ${className}` : "iso-line",
        "data-tone": tone,
        "data-free": free === true ? "" : free || undefined,
        "data-dotted": dotted ? "" : undefined,
        "data-dashed": dashed ? "" : undefined,
        "data-flow": flow ? "" : undefined,
        d,
    })}/>`;
}
export function dotsSvg(points, { size = 0.5, tone = "mid", pulse = false } = {}) {
    const dots = points
        .map(([x, y, alpha], index) => `<circle cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"${alpha !== undefined && alpha < 1 ? ` fill-opacity="${Math.max(0, alpha).toFixed(2)}"` : ""}${pulse ? ` data-pulse="" style="--pulse-at:${(index * 137) % 1600}ms"` : ""}/>`)
        .join("");
    return `<g class="iso-dots" data-tone="${tone}">${dots}</g>`;
}
export function fadedSvg(list, style = {}) {
    const groups = new Map();
    for (const entry of list) {
        const [d, alpha] = Array.isArray(entry) ? entry : [entry.d, entry.alpha];
        if (!d || !(alpha > 0.02))
            continue;
        const level = Math.min(1, Math.round(alpha * 20) / 20);
        groups.set(level, (groups.get(level) ?? "") + d);
    }
    return [...groups].map(([alpha, d]) => (alpha >= 1 ? lineSvg(d, style) : `<g opacity="${alpha.toFixed(2)}">${lineSvg(d, style)}</g>`)).join("");
}
export function fadeLineSvg(points, { fade = [0, 0], tone = "lo", className, stops = 6 } = {}) {
    if (points.length < 2)
        return "";
    const ease = (t) => t * t * (3 - 2 * t);
    const hashOf = (text) => {
        let hash = 2166136261;
        for (let index = 0; index < text.length; index++)
            hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
        return (hash >>> 0).toString(36);
    };
    const lengths = [0];
    for (let index = 1; index < points.length; index++)
        lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
    const at = (length) => {
        let index = 1;
        while (index < points.length - 1 && lengths[index] < length)
            index++;
        const span = lengths[index] - lengths[index - 1] || 1;
        const t = Math.max(0, Math.min(1, (length - lengths[index - 1]) / span));
        return [points[index - 1][0] + (points[index][0] - points[index - 1][0]) * t, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * t];
    };
    const pieceOf = (from, to) => [at(from), ...points.filter((_, index) => lengths[index] > from + 1e-6 && lengths[index] < to - 1e-6), at(to)];
    const total = lengths[lengths.length - 1];
    if (total < 1e-6)
        return "";
    let [start, end] = Array.isArray(fade) ? fade : [fade, fade];
    if (start + end > total) {
        const share = total / (start + end);
        start *= share;
        end *= share;
    }
    const paint = `var(--anatomy-${tone})`;
    const ramp = (piece, rising) => {
        const d = pathOf(piece);
        const [x1, y1] = piece[0];
        const [x2, y2] = piece[piece.length - 1];
        const id = `iso-fade-${hashOf(`${d}|${tone}|${rising}`)}`;
        const marks = Array.from({ length: stops + 1 }, (_, index) => {
            const t = index / stops;
            const alpha = ease(rising ? t : 1 - t);
            return `<stop offset="${t.toFixed(3)}" stop-opacity="${alpha.toFixed(3)}" style="stop-color:${paint}"/>`;
        }).join("");
        const gradient = `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${tenth(x1)}" y1="${tenth(y1)}" x2="${tenth(x2)}" y2="${tenth(y2)}">${marks}</linearGradient>`;
        return `<defs>${gradient}</defs><path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": rising ? "start" : "end", d, style: `stroke:url(#${id})` })}/>`;
    };
    const parts = [];
    if (start > 0.05)
        parts.push(ramp(pieceOf(0, start), true));
    if (total - end - start > 0.05)
        parts.push(lineSvg(pathOf(pieceOf(start, total - end)), { tone, className }));
    if (end > 0.05)
        parts.push(ramp(pieceOf(total - end, total), false));
    return `<g class="iso-fade">${parts.join("")}</g>`;
}
export function wireSvg(from, to, { via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 } = {}) {
    const d = pathOf([from, ...via, to]);
    const end = ([x, y]) => `<circle class="iso-wire-end" cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"/>`;
    return `<g class="iso-wire" data-tone="${tone}">${lineSvg(d, { tone, dotted, dashed, flow })}${ends === "both" || ends === "from" ? end(from) : ""}${ends === "both" || ends === "to" ? end(to) : ""}</g>`;
}
export function faceTextSvg(transform, text, { size = 3, tone = "mid", anchor = "start" } = {}) {
    return `<text class="iso-face-text" data-tone="${tone}" transform="${transform}" font-size="${size}" text-anchor="${anchor}">${escape(text)}</text>`;
}
export function groupSvg(children, extra = {}) {
    return `<g${attrs(extra)}>${Array.isArray(children) ? children.join("") : children}</g>`;
}
export function figureSvg({ width, height, label, body, standalone = false, className }) {
    const style = standalone ? `<style>${ISO_CSS}</style>` : "";
    return `<svg${attrs({
        xmlns: "http://www.w3.org/2000/svg",
        class: `iso-svg${standalone ? " iso" : ""}${className ? ` ${className}` : ""}`,
        "data-theme": standalone || undefined,
        viewBox: `0 0 ${width} ${height}`,
        role: "img",
        "aria-label": label,
    })}>${style}${Array.isArray(body) ? body.join("") : body}</svg>`;
}
function swatchSvg(mark) {
    if (mark === "raised" || mark === "flat" || mark === "lit") {
        const body = mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z";
        const crease = mark === "flat" ? "" : `<path class="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5"/>`;
        return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="${body}"/>${crease}</svg>`;
    }
    return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="M1.5 5h13"/></svg>`;
}
export function plateHtml({ fig, title, hint, readout = "", keys = [], caption, body }) {
    const corner = (name, text, extra = "") => `<span class="iso-plate-corner" data-corner="${name}"${extra}>${escape(text)}</span>`;
    const legend = keys.length || caption
        ? `<figcaption class="iso-legend">${keys.length ? `<span class="iso-keys">${keys.map((key) => `<span class="iso-key">${swatchSvg(key.mark)}${escape(key.label)}</span>`).join("")}</span>` : ""}${caption ? `<span class="iso-caption">${escape(caption)}</span>` : ""}</figcaption>`
        : "";
    return `<figure class="iso-figure"><div class="iso-plate"><div class="iso-plate-corners" aria-hidden="true">${corner("fig", fig)}${title ? corner("title", title) : ""}${hint ? corner("hint", hint) : ""}${corner("readout", readout, " data-readout")}</div>${body}</div>${legend}</figure>`;
}
export function pageHtml({ title, theme = "dark", body, script = "", width = 650 }) {
    return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${ISO_CSS}${PAGE_CSS.replace("__WIDTH__", String(width))}</style></head><body class="iso" data-theme="${theme}"><main class="iso-page">${body}</main>${script ? `<script type="module">${script.replace(/<\/(script)/gi, "<\\/$1")}</script>` : ""}</body></html>`;
}
const PAGE_CSS = `
*{box-sizing:border-box}
html{color-scheme:dark}
body[data-theme="light"]{color-scheme:light}
body{margin:0;background:var(--anatomy-page);color:var(--anatomy-text);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
.iso-page{width:min(__WIDTH__px,100% - 32px);margin:64px auto 96px}
`;
export const ISO_CSS = `
.iso{
  --anatomy-page:#0b0b0b;--anatomy-card:#131313;--anatomy-text:#a3a3a3;--anatomy-strong:#e6e6e6;--anatomy-muted:#6e6e6e;
  --anatomy-paper:#131313;--anatomy-face:#1b1b1b;--anatomy-top:#1c1c1c;
  --anatomy-shade-0:#0f0f0f;--anatomy-shade-1:#121212;--anatomy-shade-2:#151515;--anatomy-shade-3:#181818;
  --anatomy-lit-top:#262626;--anatomy-lit-shade:#1d1d1d;
  --anatomy-hi:#d4d4d4;--anatomy-mid:#6a6a6a;--anatomy-lo:#3e3e3e;--anatomy-faint:#2a2a2a;--anatomy-lit:#ffffff;
  --anatomy-halo:#050505;--anatomy-halo-opacity:.9;--anatomy-dot:#e6e6e6;
  --anatomy-red:#ff6b6b;--anatomy-green:#6be0a0;--anatomy-blue:#6b9bff;
  --anatomy-weight:.6px;--anatomy-ease:cubic-bezier(.32,.72,0,1);
  --anatomy-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  --anatomy-lift:inset 0 1px 0 0 #ffffff0a,inset 0 0 0 1px #ffffff06,0 0 0 1px #00000026,0 1px 1px -.5px #00000014,0 3px 3px -1.5px #00000014,0 6px 6px -3px #00000014,0 12px 12px -6px #00000014;
}
.iso[data-theme="light"]{
  --anatomy-page:#f2f2ef;--anatomy-card:#fbfbfa;--anatomy-text:#57575a;--anatomy-strong:#18181a;--anatomy-muted:#8c8c90;
  --anatomy-paper:#fbfbfa;--anatomy-face:#ffffff;--anatomy-top:#ffffff;
  --anatomy-shade-0:#dcdcd9;--anatomy-shade-1:#e5e5e2;--anatomy-shade-2:#ededea;--anatomy-shade-3:#f4f4f1;
  --anatomy-lit-top:#ffffff;--anatomy-lit-shade:#eeeeeb;
  --anatomy-hi:#26262a;--anatomy-mid:#8a8a8f;--anatomy-lo:#b4b4b8;--anatomy-faint:#d4d4d6;--anatomy-lit:#000000;
  --anatomy-halo:#8a8a80;--anatomy-halo-opacity:.32;--anatomy-dot:#3a3a3e;
  --anatomy-red:#e5484d;--anatomy-green:#30a46c;--anatomy-blue:#3e63dd;
  --anatomy-lift:inset 0 1px 0 0 #ffffff,0 0 0 1px #0000000f,0 1px 1px -.5px #0000000a,0 3px 3px -1.5px #0000000a,0 6px 6px -3px #0000000a,0 12px 12px -6px #0000000a;
}
.iso-svg{display:block;width:100%;height:auto;overflow:visible}
.iso-svg path,.iso-svg circle{vector-effect:non-scaling-stroke}
.iso-fill{fill:var(--anatomy-paper);stroke:none}
.iso-solid[data-lit]>.iso-fill{fill:var(--anatomy-face)}
.iso-shade,.iso-top{stroke:none}
.iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)}
.iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)}
.iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)}
.iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)}
.iso-top{fill:var(--anatomy-top)}
.iso-solid[data-lit]>.iso-top{fill:var(--anatomy-lit-top)}
.iso-solid[data-lit]>.iso-shade{fill:var(--anatomy-lit-shade)}
.iso-solid[data-seamless]>.iso-shade,.iso-solid[data-seamless]>.iso-top{stroke-width:.4;stroke-linejoin:round}
.iso-solid[data-seamless]>.iso-shade[data-shade="0"]{stroke:var(--anatomy-shade-0)}
.iso-solid[data-seamless]>.iso-shade[data-shade="1"]{stroke:var(--anatomy-shade-1)}
.iso-solid[data-seamless]>.iso-shade[data-shade="2"]{stroke:var(--anatomy-shade-2)}
.iso-solid[data-seamless]>.iso-shade[data-shade="3"]{stroke:var(--anatomy-shade-3)}
.iso-solid[data-seamless]>.iso-top{stroke:var(--anatomy-top)}
.iso-solid[data-seamless][data-lit]>.iso-top{stroke:var(--anatomy-lit-top)}
.iso-solid[data-seamless][data-lit]>.iso-shade{stroke:var(--anatomy-lit-shade)}
.iso-solid[data-lit]>.iso-edge{stroke:var(--anatomy-lit)}
.iso-halo{fill:var(--anatomy-halo);stroke:none;filter:blur(9px);opacity:var(--anatomy-halo-opacity);transform:translateY(3px)}
.iso-line{fill:none;stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight);stroke-linecap:round;stroke-linejoin:round}
.iso-line[data-tone="hi"]{stroke:var(--anatomy-hi)}
.iso-line[data-tone="mid"]{stroke:var(--anatomy-mid)}
.iso-line[data-tone="lo"]{stroke:var(--anatomy-lo)}
.iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)}
.iso-line[data-tone="lit"]{stroke:var(--anatomy-lit)}
.iso-line[data-tone="red"]{stroke:var(--anatomy-red)}
.iso-line[data-tone="green"]{stroke:var(--anatomy-green)}
.iso-line[data-tone="blue"]{stroke:var(--anatomy-blue)}
.iso-line[data-dotted]{stroke-dasharray:.01 3.5;stroke-width:1}
.iso-line[data-dashed]{stroke-dasharray:3 3}
.iso-line[data-flow]{stroke-dasharray:2 6}
.iso-wire-end{fill:var(--anatomy-paper);stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight)}
.iso-wire[data-tone="hi"] .iso-wire-end{stroke:var(--anatomy-hi)}
.iso-wire[data-tone="mid"] .iso-wire-end{stroke:var(--anatomy-mid)}
.iso-wire[data-tone="faint"] .iso-wire-end{stroke:var(--anatomy-faint)}
.iso-wire[data-tone="lit"] .iso-wire-end{stroke:var(--anatomy-lit)}
.iso-dots circle{fill:var(--anatomy-dot);stroke:none;opacity:.32}
.iso-dots[data-tone="hi"] circle{opacity:.85}
.iso-dots[data-tone="mid"] circle{opacity:.45}
.iso-dots[data-tone="lo"] circle{opacity:.22}
.iso-dots[data-tone="faint"] circle{opacity:.12}
.iso-face-text{fill:var(--anatomy-mid);font-family:var(--anatomy-mono);letter-spacing:.04em}
.iso-face-text[data-tone="hi"]{fill:var(--anatomy-hi)}
.iso-face-text[data-tone="lo"]{fill:var(--anatomy-lo)}
.iso-face-text[data-tone="faint"]{fill:var(--anatomy-faint)}
.tb-body{fill:var(--anatomy-shade-2);stroke:none}
.tb-shine{fill:var(--anatomy-top);stroke:none}
.tb-shade{fill:var(--anatomy-shade-0);stroke:none}
.tb-ring{fill:none;stroke:var(--anatomy-lo);stroke-width:.45;stroke-linecap:butt;stroke-linejoin:round}
.tb-ring[data-tone="mid"]{stroke:var(--anatomy-mid)}
.tb-ring[data-tone="faint"]{stroke:var(--anatomy-faint)}
.tb-ring[data-tone="hi"]{stroke:var(--anatomy-hi)}
@keyframes iso-flow{to{stroke-dashoffset:-32}}
@keyframes iso-pulse{0%,100%{opacity:.18}12%{opacity:1}40%{opacity:.18}}
.iso-line[data-flow]{animation:iso-flow 1600ms linear infinite}
.iso-dots circle[data-pulse]{animation:iso-pulse 1600ms steps(1,end) infinite;animation-delay:var(--pulse-at,0ms)}
.iso-figure{margin:32px 0 36px}
.iso-plate{position:relative;padding:52px 24px;border-radius:0;background:var(--anatomy-card);box-shadow:0 0 0 1px var(--anatomy-mid);--anatomy-paper:var(--anatomy-card)}
.iso-plate-corners{position:absolute;inset:18px 20px;pointer-events:none}
.iso-plate-corner{position:absolute;color:var(--anatomy-muted);font-family:var(--anatomy-mono);font-size:10.5px;line-height:1;letter-spacing:.04em;white-space:nowrap}
.iso-plate-corner[data-corner="fig"]{top:0;left:0;color:var(--anatomy-text)}
.iso-plate-corner[data-corner="title"]{top:0;right:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="hint"]{bottom:0;left:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="readout"]{right:0;bottom:0;color:var(--anatomy-text);font-variant-numeric:tabular-nums}
.iso-plate .iso-svg{margin:0 auto}
.iso-legend{display:grid;gap:8px;margin-top:12px;color:var(--anatomy-muted);font-size:13px;line-height:1.55}
.iso-keys{display:flex;flex-wrap:wrap;gap:6px 18px}
.iso-key{display:inline-flex;align-items:center;gap:7px}
.iso-swatch{width:16px;height:10px;flex:none;overflow:visible}
.iso-swatch path{fill:none;stroke:var(--anatomy-hi);stroke-width:1;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
.iso-swatch[data-mark="raised"] path,.iso-swatch[data-mark="flat"] path{stroke:var(--anatomy-mid)}
.iso-swatch[data-mark="lit"] path{stroke:var(--anatomy-lit)}
.iso-swatch[data-mark="dim"] path{stroke:var(--anatomy-lo)}
.iso-swatch[data-mark="dotted"] path{stroke-dasharray:.01 3;stroke-width:1.4}
.iso-swatch[data-mark="dashed"] path{stroke-dasharray:3 2.5}
.iso-swatch[data-mark="red"] path{stroke:var(--anatomy-red)}
.iso-swatch[data-mark="green"] path{stroke:var(--anatomy-green)}
.iso-swatch[data-mark="blue"] path{stroke:var(--anatomy-blue)}
.iso-swatch-crease{stroke:var(--anatomy-lo)!important}
@media (max-width:560px){.iso-plate{padding:48px 12px 70px}.iso-plate-corner[data-corner="hint"]{bottom:18px}}
@media (prefers-reduced-motion:reduce){.iso-line[data-flow],.iso-dots circle[data-pulse]{animation:none}}
`;
```

## kit/iso-kit.ts

```ts
export type Flat = [number, number];
export type Point3 = [number, number, number];
export type Plane = [number, number];

export interface Projection {
  origin: Flat;
  scale: number;
  azimuth?: number;
  elevation?: number;
}

export interface Plan {
  x: number;
  y: number;
  w: number;
  d: number;
  r: number;
}

export interface SolidPaths {
  fill: string;
  outline: string;
  crease: string;
  top: string;
  shades: string[];
  bevel?: string;
  seamless?: boolean;
}

export type Tone = "hi" | "mid" | "lo" | "faint";
export type LineTone = Tone | "lit" | "red" | "green" | "blue";

export const AZIMUTH = 45;
export const ELEVATION = 30;
export const SHADES = 4;

const RADIANS = Math.PI / 180;
const UNIT = Math.sqrt(1.6);

interface Camera {
  sinA: number;
  cosA: number;
  sinE: number;
  cosE: number;
  k: number;
}

export function cameraOf({ scale, azimuth = AZIMUTH, elevation = ELEVATION }: Projection): Camera {
  const a = azimuth * RADIANS;
  const e = elevation * RADIANS;
  return { sinA: Math.sin(a), cosA: Math.cos(a), sinE: Math.sin(e), cosE: Math.cos(e), k: scale * UNIT };
}

export function iso([x, y, z]: Point3, projection: Projection): Flat {
  const { origin } = projection;
  const { sinA, cosA, sinE, cosE, k } = cameraOf(projection);
  return [origin[0] + (x * sinA - y * cosA) * k, origin[1] + ((x * cosA + y * sinA) * sinE - z * cosE) * k];
}

export function towardViewer(projection: Projection): Plane {
  const { sinA, cosA } = cameraOf(projection);
  return [cosA, sinA];
}

export function depthOf([x, y, z]: Point3, projection: Projection) {
  const { sinA, cosA, sinE, cosE } = cameraOf(projection);
  return (x * cosA + y * sinA) * cosE + z * sinE;
}

const tenth = (value: number) => {
  const text = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, "");
  return text === "-0" ? "0" : text;
};

export function pathOf(points: Flat[], closed = false) {
  if (!points.length) return "";
  return points.map(([x, y], at) => `${at ? "L" : "M"}${tenth(x)} ${tenth(y)}`).join("") + (closed ? "Z" : "");
}

export const joinPaths = (paths: string[]) => paths.filter(Boolean).join("");

export function roundedPlan({ x, y, w, d, r }: Plan, steps = 8): Plane[] {
  const radius = Math.max(0, Math.min(r, w / 2, d / 2));
  const corners: [number, number, number][] = [
    [x + w - radius, y + radius, -90],
    [x + w - radius, y + d - radius, 0],
    [x + radius, y + d - radius, 90],
    [x + radius, y + radius, 180],
  ];
  const out: Plane[] = [];
  for (const [cx, cy, start] of corners) {
    const count = radius > 0 ? steps : 0;
    for (let step = 0; step <= count; step++) {
      const angle = (start + (count ? (90 * step) / count : 45)) * RADIANS;
      out.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]);
    }
  }
  return out;
}

function signedArea(points: Plane[] | Flat[]) {
  let area = 0;
  for (let index = 0; index < points.length; index++) {
    const [ax, ay] = points[index]!;
    const [bx, by] = points[(index + 1) % points.length]!;
    area += ax * by - bx * ay;
  }
  return area / 2;
}

const oriented = (points: Flat[]) => (signedArea(points) < 0 ? [...points].reverse() : points);

function convexHull(points: Flat[]): Flat[] {
  const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  if (sorted.length < 3) return sorted;
  const cross = (o: Flat, a: Flat, b: Flat) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lower: Flat[] = [];
  for (const point of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) lower.pop();
    lower.push(point);
  }
  const upper: Flat[] = [];
  for (let index = sorted.length - 1; index >= 0; index--) {
    const point = sorted[index]!;
    while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) upper.pop();
    upper.push(point);
  }
  return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}

function runsOf(flags: boolean[], want: boolean) {
  const count = flags.length;
  const runs: number[][] = [];
  const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
  if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
  let run: number[] | null = null;
  for (let step = 0; step < count; step++) {
    const index = (start + step) % count;
    if (flags[index] === want) {
      if (!run) run = [index];
      run.push((index + 1) % count);
    } else if (run) {
      runs.push(run);
      run = null;
    }
  }
  if (run) runs.push(run);
  return runs;
}

export interface ExtrudeOptions {
  convex?: boolean;
  bevel?: number;
}

export function extrude(ring: Plane[], z: number, height: number, projection: Projection, options: ExtrudeOptions = {}): SolidPaths {
  const convex = options.convex ?? true;
  const count = ring.length;
  const counter = signedArea(ring) > 0;
  const [vx, vy] = towardViewer(projection);
  const { sinA, cosA } = cameraOf(projection);
  const normals = ring.map(([ax, ay], index) => {
    const [bx, by] = ring[(index + 1) % count]!;
    const dx = bx - ax;
    const dy = by - ay;
    const length = Math.hypot(dx, dy) || 1;
    return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane;
  });
  const facing = normals.map(([nx, ny]) => nx * vx + ny * vy > 1e-9);
  const top = ring.map(([x, y]) => iso([x, y, z + height], projection));
  const bottom = ring.map(([x, y]) => iso([x, y, z], projection));
  const outline: string[] = [];
  const crease: string[] = [];
  for (const run of runsOf(facing, false)) outline.push(pathOf(run.map((index) => top[index]!)));
  for (const run of runsOf(facing, true)) {
    crease.push(pathOf(run.map((index) => top[index]!)));
    if (height > 0) outline.push(pathOf(run.map((index) => bottom[index]!)));
  }
  if (height > 0) {
    for (let index = 0; index < count; index++) {
      if (facing[(index + count - 1) % count] !== facing[index]) outline.push(pathOf([top[index]!, bottom[index]!]));
    }
  }
  let fill: string;
  if (convex) {
    fill = pathOf(convexHull([...top, ...bottom]), true);
  } else {
    const parts = [pathOf(oriented(top), true), pathOf(oriented(bottom), true)];
    for (let index = 0; index < count; index++) {
      if (!facing[index]) continue;
      const next = (index + 1) % count;
      parts.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true));
    }
    fill = parts.join("");
  }
  const buckets: string[][] = Array.from({ length: SHADES }, () => []);
  if (height > 0) {
    const [lx, ly] = [-sinA, cosA];
    for (let index = 0; index < count; index++) {
      if (!facing[index]) continue;
      const [nx, ny] = normals[index]!;
      const lit = (nx * lx + ny * ly + 1) / 2;
      const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES)));
      const next = (index + 1) % count;
      buckets[shade]!.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true));
    }
  }
  let bevel: string | undefined;
  if (options.bevel && options.bevel > 0) {
    const by = options.bevel;
    const inset = ring.map(([x, y], index) => {
      const [ax, ay] = normals[(index + count - 1) % count]!;
      const [bx, by2] = normals[index]!;
      const mx = ax + bx;
      const my = ay + by2;
      const length = Math.hypot(mx, my) || 1;
      return [x - (mx / length) * by, y - (my / length) * by] as Plane;
    });
    const insetTop = inset.map(([x, y]) => iso([x, y, z + height], projection));
    bevel = runsOf(facing, true)
      .map((run) => pathOf([top[run[0]!]!, ...run.slice(1, -1).map((index) => insetTop[index]!), top[run[run.length - 1]!]!]))
      .join("");
  }
  return {
    fill,
    outline: outline.join(""),
    crease: crease.join(""),
    top: pathOf(oriented(top), true),
    shades: buckets.map((paths) => paths.join("")),
    bevel,
  };
}

export const ringOf = (plan: Plan, steps = 8): Plane[] => roundedPlan(plan, steps);

export function slabOf(plan: Plan, z: number, height: number, projection: Projection, steps = 8, bevel = 0) {
  return extrude(ringOf(plan, steps), z, height, projection, { convex: true, bevel });
}

export function circleRing(cx: number, cy: number, radius: number, steps = 40): Plane[] {
  return Array.from({ length: steps }, (_, step) => {
    const angle = (step / steps) * Math.PI * 2;
    return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)] as Plane;
  });
}

export function cylinder(cx: number, cy: number, radius: number, z: number, height: number, projection: Projection, steps = 32, bevel = 0) {
  return extrude(circleRing(cx, cy, radius, steps), z, height, projection, { convex: true, bevel });
}

export const insetPlan = ({ x, y, w, d, r }: Plan, by: number): Plan => ({ x: x + by, y: y + by, w: w - by * 2, d: d - by * 2, r: Math.max(0, r - by) });

export function onTop(points: Plane[], z: number, projection: Projection, closed = false) {
  return pathOf(
    points.map(([x, y]) => iso([x, y, z], projection)),
    closed,
  );
}

export const planOutline = (plan: Plan, z: number, projection: Projection, steps = 8) => onTop(ringOf(plan, steps), z, projection, true);

export const lineOnTop = (from: Plane, to: Plane, z: number, projection: Projection) => onTop([from, to], z, projection);

export const segment = (from: Point3, to: Point3, projection: Projection) => pathOf([iso(from, projection), iso(to, projection)]);

export const rise = (at: Plane, from: number, to: number, projection: Projection) => segment([at[0], at[1], from], [at[0], at[1], to], projection);

export const ring = (cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 40) =>
  onTop(circleRing(cx, cy, radius, steps), z, projection, true);

export const pointsAt = (points: Point3[], projection: Projection): Flat[] => points.map((point) => iso(point, projection));

export function dotGrid(x: number, y: number, columns: number, rows: number, gap: number, z: number, projection: Projection): Flat[] {
  const dots: Flat[] = [];
  for (let row = 0; row < rows; row++) {
    for (let column = 0; column < columns; column++) dots.push(iso([x + column * gap, y + row * gap, z], projection));
  }
  return dots;
}

export function corners({ x, y, w, d }: Plan, inset = 0): Plane[] {
  return [
    [x + inset, y + inset],
    [x + w - inset, y + inset],
    [x + inset, y + d - inset],
    [x + w - inset, y + d - inset],
  ];
}

export function boxCorners({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] {
  const out: Point3[] = [];
  for (const px of [x, x + w]) for (const py of [y, y + d]) for (const z of [z0, z1]) out.push([px, py, z]);
  return out;
}

export function knurl(cx: number, cy: number, radius: number, z0: number, z1: number, count: number, projection: Projection, inset = 0.4) {
  const [vx, vy] = towardViewer(projection);
  const lines: string[] = [];
  for (let index = 0; index < count; index++) {
    const angle = (index / count) * Math.PI * 2;
    const nx = Math.cos(angle);
    const ny = Math.sin(angle);
    if (nx * vx + ny * vy < 0.15) continue;
    lines.push(segment([cx + radius * nx, cy + radius * ny, z0 + inset], [cx + radius * nx, cy + radius * ny, z1 - inset], projection));
  }
  return lines.join("");
}

export function sideArc(cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 24) {
  const [vx, vy] = towardViewer(projection);
  const facing = Math.atan2(vy, vx);
  const points: Flat[] = [];
  for (let step = 0; step <= steps; step++) {
    const angle = facing - Math.PI / 2 + (step / steps) * Math.PI;
    points.push(iso([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle), z], projection));
  }
  return pathOf(points);
}

export function ringSeam(ring: Plane[], z: number, projection: Projection) {
  const count = ring.length;
  const counter = signedArea(ring) > 0;
  const [vx, vy] = towardViewer(projection);
  const facing = ring.map(([ax, ay], index) => {
    const [bx, by] = ring[(index + 1) % count];
    const dx = bx - ax;
    const dy = by - ay;
    const [nx, ny] = counter ? [dy, -dx] : [-dy, dx];
    return nx * vx + ny * vy > 1e-9;
  });
  return runsOf(facing, true).map((run) => pathOf(run.map((index) => iso([ring[index][0], ring[index][1], z], projection)))).join("");
}

export const sideSeam = (plan: Plan, z: number, projection: Projection, steps = 8) => ringSeam(ringOf(plan, steps), z, projection);

export function sideRing(center: Point3, radius: number, axis: "x" | "y", projection: Projection, steps = 20) {
  const [px, py, pz] = center;
  const points: Flat[] = [];
  for (let step = 0; step < steps; step++) {
    const angle = (step / steps) * Math.PI * 2;
    const u = radius * Math.cos(angle);
    const v = radius * Math.sin(angle);
    points.push(iso(axis === "x" ? [px, py + u, pz + v] : [px + u, py, pz + v], projection));
  }
  return pathOf(points, true);
}

export function coil(from: number, to: number, center: Plane, radius: number, turns: number, axis: "x" | "y", projection: Projection, perTurn = 16) {
  const count = Math.max(1, Math.round(turns * perTurn));
  const back: string[] = [];
  const front: string[] = [];
  let run: Flat[] = [];
  let near: boolean | null = null;
  for (let index = 0; index <= count; index++) {
    const share = index / count;
    const angle = share * turns * Math.PI * 2;
    const u = radius * Math.cos(angle);
    const v = radius * Math.sin(angle);
    const along = from + (to - from) * share;
    const point: Point3 = axis === "x" ? [along, center[0] + u, center[1] + v] : [center[0] + u, along, center[1] + v];
    const normal: Point3 = axis === "x" ? [0, Math.cos(angle), Math.sin(angle)] : [Math.cos(angle), 0, Math.sin(angle)];
    const facing = depthOf(normal, projection) > 0;
    const flat = iso(point, projection);
    if (near === null) near = facing;
    if (facing !== near) {
      run.push(flat);
      (near ? front : back).push(pathOf(run));
      run = [flat];
      near = facing;
      continue;
    }
    run.push(flat);
  }
  if (run.length > 1) (near ? front : back).push(pathOf(run));
  return { back: back.join(""), front: front.join("") };
}

export interface Ticks {
  minor: string;
  major: string;
}

export function topTicks(from: number, to: number, step: number, every: number, edge: number, z: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks {
  const minor: string[] = [];
  const major: string[] = [];
  const first = Math.ceil(from / step) * step;
  for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
    const big = index % every === 0;
    const length = big ? lengths[1] : lengths[0];
    const a: Plane = axis === "x" ? [at, edge] : [edge, at];
    const b: Plane = axis === "x" ? [at, edge + length] : [edge + length, at];
    (big ? major : minor).push(lineOnTop(a, b, z, projection));
  }
  return { minor: minor.join(""), major: major.join("") };
}

export function sideTicks(from: number, to: number, step: number, every: number, face: number, top: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks {
  const minor: string[] = [];
  const major: string[] = [];
  const first = Math.ceil(from / step) * step;
  for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
    const big = index % every === 0;
    const length = big ? lengths[1] : lengths[0];
    const a: Point3 = axis === "x" ? [at, face, top] : [face, at, top];
    const b: Point3 = axis === "x" ? [at, face, top - length] : [face, at, top - length];
    (big ? major : minor).push(segment(a, b, projection));
  }
  return { minor: minor.join(""), major: major.join("") };
}

export function radialTicks(cx: number, cy: number, radius: number, count: number, every: number, z: number, lengths: [number, number], projection: Projection, from = 0, span = Math.PI * 2): Ticks {
  const minor: string[] = [];
  const major: string[] = [];
  const closed = Math.abs(span - Math.PI * 2) < 1e-9;
  const steps = closed ? count : count + 1;
  for (let index = 0; index < steps; index++) {
    const angle = from + (index / count) * span;
    const big = index % every === 0;
    const inner = radius - (big ? lengths[1] : lengths[0]);
    const sx = Math.sin(angle);
    const sy = -Math.cos(angle);
    (big ? major : minor).push(lineOnTop([cx + sx * inner, cy + sy * inner], [cx + sx * radius, cy + sy * radius], z, projection));
  }
  return { minor: minor.join(""), major: major.join("") };
}

export const haloOf = (plan: Plan, z: number, height: number, projection: Projection) => slabOf(plan, z, height, projection).fill;

export function axisVector(projection: Projection, [dx, dy, dz]: Point3): Flat {
  const origin = iso([0, 0, 0], projection);
  const moved = iso([dx, dy, dz], projection);
  return [moved[0] - origin[0], moved[1] - origin[1]];
}

const fine = (value: number) => Math.round(value * 100) / 100;

export function translateAlong(projection: Projection, by: Point3) {
  const [x, y] = axisVector(projection, by);
  return `translate(${fine(x)} ${fine(y)})`;
}

const thousandth = (value: number) => Math.round(value * 1000) / 1000;

function axesAt(at: Plane, z: number, projection: Projection) {
  const origin = iso([at[0], at[1], z], projection);
  const along = (point: Point3): Flat => {
    const [px, py] = iso(point, projection);
    return [px - origin[0], py - origin[1]];
  };
  return { origin, x: along([at[0] + 1, at[1], z]), y: along([at[0], at[1] + 1, z]), z: along([at[0], at[1], z + 1]) };
}

const matrixOf = (u: Flat, v: Flat, origin: Flat) =>
  `matrix(${thousandth(u[0])} ${thousandth(u[1])} ${thousandth(v[0])} ${thousandth(v[1])} ${thousandth(origin[0])} ${thousandth(origin[1])})`;

export function holdStill(element: Element, [dx, dy]: Flat) {
  const text = dx || dy ? `translate(${-dx} ${-dy})` : "";
  element.querySelectorAll("[data-hold]").forEach((node) => (text ? node.setAttribute("transform", text) : node.removeAttribute("transform")));
}

export function topMatrix(at: Plane, z: number, projection: Projection, along: "x" | "y" = "x") {
  const { origin, x, y } = axesAt(at, z, projection);
  return along === "x" ? matrixOf(x, y, origin) : matrixOf([-y[0], -y[1]], x, origin);
}

export function sideMatrix(at: Plane, z: number, projection: Projection, face: "left" | "right") {
  const { origin, x, y, z: up } = axesAt(at, z, projection);
  const down: Flat = [-up[0], -up[1]];
  return face === "left" ? matrixOf(x, down, origin) : matrixOf([-y[0], -y[1]], down, origin);
}

export function screenRise(height: number, projection: Projection) {
  const { cosE, k } = cameraOf(projection);
  return height * cosE * k;
}

export interface FitOptions {
  pad?: number;
  headroom?: number;
  azimuth?: number;
  elevation?: number;
}

export function fitProjection(points: Point3[], width: number, height: number, { pad = 24, headroom = 0, azimuth = AZIMUTH, elevation = ELEVATION }: FitOptions = {}): Projection {
  const unit: Projection = { origin: [0, 0], scale: 1, azimuth, elevation };
  const flat = points.map((point) => iso(point, unit));
  const xs = flat.map(([x]) => x);
  const ys = flat.map(([, y]) => y);
  const left = Math.min(...xs);
  const right = Math.max(...xs);
  const up = Math.min(...ys);
  const down = Math.max(...ys);
  const scale = Math.min((width - pad * 2) / (right - left), (height - pad * 2 - headroom) / (down - up));
  return {
    origin: [width / 2 - ((left + right) / 2) * scale, headroom + (height - headroom) / 2 - ((up + down) / 2) * scale],
    scale,
    azimuth,
    elevation,
  };
}

const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

const attrs = (pairs: Record<string, string | number | undefined | false>) =>
  Object.entries(pairs)
    .filter(([, value]) => value !== undefined && value !== false)
    .map(([key, value]) => ` ${key}="${escape(String(value))}"`)
    .join("");

export interface SolidStyle {
  tone?: Tone;
  crease?: Tone | "none";
  halo?: boolean;
  lit?: boolean;
  flat?: boolean;
  className?: string;
  inner?: string;
}

export function solidSvg(paths: SolidPaths, { tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner = "" }: SolidStyle = {}) {
  const parts: string[] = [];
  if (halo) parts.push(`<path class="iso-halo" d="${paths.fill}"/>`);
  parts.push(`<path class="iso-fill" d="${paths.fill}"/>`);
  if (!flat) {
    paths.shades.forEach((d, shade) => {
      if (d) parts.push(`<path class="iso-shade" data-shade="${shade}" d="${d}"/>`);
    });
    parts.push(`<path class="iso-top" d="${paths.top}"/>`);
  }
  if (crease !== "none" && paths.crease) parts.push(`<path class="iso-line iso-crease" data-tone="${crease}" d="${paths.crease}"/>`);
  if (crease !== "none" && paths.bevel) parts.push(`<path class="iso-line iso-bevel" data-tone="${crease}" d="${paths.bevel}"/>`);
  if (inner) parts.push(inner);
  parts.push(`<path class="iso-line iso-edge" data-tone="${tone}" d="${paths.outline}"/>`);
  return `<g${attrs({ class: className ? `iso-solid ${className}` : "iso-solid", "data-lit": lit ? "" : undefined, "data-seamless": paths.seamless ? "" : undefined })}>${parts.join("")}</g>`;
}

export interface LineStyle {
  tone?: LineTone;
  dotted?: boolean;
  dashed?: boolean;
  flow?: boolean;
  className?: string;
  free?: boolean | "start" | "end";
}

export function lineSvg(d: string, { tone = "lo", dotted = false, dashed = false, flow = false, className, free }: LineStyle = {}) {
  if (!d) return "";
  return `<path${attrs({
    class: className ? `iso-line ${className}` : "iso-line",
    "data-tone": tone,
    "data-free": free === true ? "" : free || undefined,
    "data-dotted": dotted ? "" : undefined,
    "data-dashed": dashed ? "" : undefined,
    "data-flow": flow ? "" : undefined,
    d,
  })}/>`;
}

export function dotsSvg(points: Array<Flat | [number, number, number]>, { size = 0.5, tone = "mid", pulse = false }: { size?: number; tone?: Tone; pulse?: boolean } = {}) {
  const dots = points
    .map(([x, y, alpha]: number[], index) => `<circle cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"${alpha !== undefined && alpha < 1 ? ` fill-opacity="${Math.max(0, alpha).toFixed(2)}"` : ""}${pulse ? ` data-pulse="" style="--pulse-at:${(index * 137) % 1600}ms"` : ""}/>`)
    .join("");
  return `<g class="iso-dots" data-tone="${tone}">${dots}</g>`;
}

export type Faded = { d: string; alpha: number } | [string, number];

export function fadedSvg(list: Faded[], style: LineStyle = {}) {
  const groups = new Map<number, string>();
  for (const entry of list) {
    const [d, alpha] = Array.isArray(entry) ? entry : [entry.d, entry.alpha];
    if (!d || !(alpha > 0.02)) continue;
    const level = Math.min(1, Math.round(alpha * 20) / 20);
    groups.set(level, (groups.get(level) ?? "") + d);
  }
  return [...groups].map(([alpha, d]) => (alpha >= 1 ? lineSvg(d, style) : `<g opacity="${alpha.toFixed(2)}">${lineSvg(d, style)}</g>`)).join("");
}

export interface FadeLineStyle {
  fade?: number | [number, number];
  tone?: LineTone;
  className?: string;
  stops?: number;
}

export function fadeLineSvg(points: Flat[], { fade = [0, 0], tone = "lo", className, stops = 6 }: FadeLineStyle = {}) {
  if (points.length < 2) return "";
  const ease = (t: number) => t * t * (3 - 2 * t);
  const hashOf = (text: string) => {
    let hash = 2166136261;
    for (let index = 0; index < text.length; index++) hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
    return (hash >>> 0).toString(36);
  };
  const lengths = [0];
  for (let index = 1; index < points.length; index++) lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
  const at = (length: number): Flat => {
    let index = 1;
    while (index < points.length - 1 && lengths[index] < length) index++;
    const span = lengths[index] - lengths[index - 1] || 1;
    const t = Math.max(0, Math.min(1, (length - lengths[index - 1]) / span));
    return [points[index - 1][0] + (points[index][0] - points[index - 1][0]) * t, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * t];
  };
  const pieceOf = (from: number, to: number): Flat[] => [at(from), ...points.filter((_, index) => lengths[index] > from + 1e-6 && lengths[index] < to - 1e-6), at(to)];
  const total = lengths[lengths.length - 1];
  if (total < 1e-6) return "";
  let [start, end] = Array.isArray(fade) ? fade : [fade, fade];
  if (start + end > total) {
    const share = total / (start + end);
    start *= share;
    end *= share;
  }
  const paint = `var(--anatomy-${tone})`;
  const ramp = (piece: Flat[], rising: boolean) => {
    const d = pathOf(piece);
    const [x1, y1] = piece[0];
    const [x2, y2] = piece[piece.length - 1];
    const id = `iso-fade-${hashOf(`${d}|${tone}|${rising}`)}`;
    const marks = Array.from({ length: stops + 1 }, (_, index) => {
      const t = index / stops;
      const alpha = ease(rising ? t : 1 - t);
      return `<stop offset="${t.toFixed(3)}" stop-opacity="${alpha.toFixed(3)}" style="stop-color:${paint}"/>`;
    }).join("");
    const gradient = `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${tenth(x1)}" y1="${tenth(y1)}" x2="${tenth(x2)}" y2="${tenth(y2)}">${marks}</linearGradient>`;
    return `<defs>${gradient}</defs><path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": rising ? "start" : "end", d, style: `stroke:url(#${id})` })}/>`;
  };
  const parts: string[] = [];
  if (start > 0.05) parts.push(ramp(pieceOf(0, start), true));
  if (total - end - start > 0.05) parts.push(lineSvg(pathOf(pieceOf(start, total - end)), { tone, className }));
  if (end > 0.05) parts.push(ramp(pieceOf(total - end, total), false));
  return `<g class="iso-fade">${parts.join("")}</g>`;
}

export interface WireStyle {
  via?: Flat[];
  tone?: LineTone;
  dotted?: boolean;
  dashed?: boolean;
  flow?: boolean;
  ends?: "both" | "from" | "to" | "none";
  size?: number;
}

export function wireSvg(from: Flat, to: Flat, { via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 }: WireStyle = {}) {
  const d = pathOf([from, ...via, to]);
  const end = ([x, y]: Flat) => `<circle class="iso-wire-end" cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"/>`;
  return `<g class="iso-wire" data-tone="${tone}">${lineSvg(d, { tone, dotted, dashed, flow })}${ends === "both" || ends === "from" ? end(from) : ""}${ends === "both" || ends === "to" ? end(to) : ""}</g>`;
}

export function faceTextSvg(transform: string, text: string, { size = 3, tone = "mid", anchor = "start" }: { size?: number; tone?: Tone; anchor?: "start" | "middle" | "end" } = {}) {
  return `<text class="iso-face-text" data-tone="${tone}" transform="${transform}" font-size="${size}" text-anchor="${anchor}">${escape(text)}</text>`;
}

export function groupSvg(children: string | string[], extra: Record<string, string | number | undefined | false> = {}) {
  return `<g${attrs(extra)}>${Array.isArray(children) ? children.join("") : children}</g>`;
}

export type Theme = "dark" | "light";

export interface FigureOptions {
  width: number;
  height: number;
  label: string;
  body: string | string[];
  standalone?: Theme | false;
  className?: string;
}

export function figureSvg({ width, height, label, body, standalone = false, className }: FigureOptions) {
  const style = standalone ? `<style>${ISO_CSS}</style>` : "";
  return `<svg${attrs({
    xmlns: "http://www.w3.org/2000/svg",
    class: `iso-svg${standalone ? " iso" : ""}${className ? ` ${className}` : ""}`,
    "data-theme": standalone || undefined,
    viewBox: `0 0 ${width} ${height}`,
    role: "img",
    "aria-label": label,
  })}>${style}${Array.isArray(body) ? body.join("") : body}</svg>`;
}

export type KeyMark = "raised" | "flat" | "lit" | "edge" | "dim" | "dotted" | "dashed" | "red" | "green" | "blue";

export interface LegendKey {
  mark: KeyMark;
  label: string;
}

function swatchSvg(mark: KeyMark) {
  if (mark === "raised" || mark === "flat" || mark === "lit") {
    const body = mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z";
    const crease = mark === "flat" ? "" : `<path class="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5"/>`;
    return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="${body}"/>${crease}</svg>`;
  }
  return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="M1.5 5h13"/></svg>`;
}

export interface PlateOptions {
  fig: string;
  title?: string;
  hint?: string;
  readout?: string;
  keys?: LegendKey[];
  caption?: string;
  body: string;
}

export function plateHtml({ fig, title, hint, readout = "", keys = [], caption, body }: PlateOptions) {
  const corner = (name: string, text: string, extra = "") => `<span class="iso-plate-corner" data-corner="${name}"${extra}>${escape(text)}</span>`;
  const legend =
    keys.length || caption
      ? `<figcaption class="iso-legend">${keys.length ? `<span class="iso-keys">${keys.map((key) => `<span class="iso-key">${swatchSvg(key.mark)}${escape(key.label)}</span>`).join("")}</span>` : ""}${caption ? `<span class="iso-caption">${escape(caption)}</span>` : ""}</figcaption>`
      : "";
  return `<figure class="iso-figure"><div class="iso-plate"><div class="iso-plate-corners" aria-hidden="true">${corner("fig", fig)}${title ? corner("title", title) : ""}${hint ? corner("hint", hint) : ""}${corner("readout", readout, " data-readout")}</div>${body}</div>${legend}</figure>`;
}

export interface PageOptions {
  title: string;
  theme?: Theme;
  body: string;
  script?: string;
  width?: number;
}

export function pageHtml({ title, theme = "dark", body, script = "", width = 650 }: PageOptions) {
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${ISO_CSS}${PAGE_CSS.replace("__WIDTH__", String(width))}</style></head><body class="iso" data-theme="${theme}"><main class="iso-page">${body}</main>${script ? `<script type="module">${script.replace(/<\/(script)/gi, "<\\/$1")}</script>` : ""}</body></html>`;
}

const PAGE_CSS = `
*{box-sizing:border-box}
html{color-scheme:dark}
body[data-theme="light"]{color-scheme:light}
body{margin:0;background:var(--anatomy-page);color:var(--anatomy-text);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
.iso-page{width:min(__WIDTH__px,100% - 32px);margin:64px auto 96px}
`;

export const ISO_CSS = `
.iso{
  --anatomy-page:#0b0b0b;--anatomy-card:#131313;--anatomy-text:#a3a3a3;--anatomy-strong:#e6e6e6;--anatomy-muted:#6e6e6e;
  --anatomy-paper:#131313;--anatomy-face:#1b1b1b;--anatomy-top:#1c1c1c;
  --anatomy-shade-0:#0f0f0f;--anatomy-shade-1:#121212;--anatomy-shade-2:#151515;--anatomy-shade-3:#181818;
  --anatomy-lit-top:#262626;--anatomy-lit-shade:#1d1d1d;
  --anatomy-hi:#d4d4d4;--anatomy-mid:#6a6a6a;--anatomy-lo:#3e3e3e;--anatomy-faint:#2a2a2a;--anatomy-lit:#ffffff;
  --anatomy-halo:#050505;--anatomy-halo-opacity:.9;--anatomy-dot:#e6e6e6;
  --anatomy-red:#ff6b6b;--anatomy-green:#6be0a0;--anatomy-blue:#6b9bff;
  --anatomy-weight:.6px;--anatomy-ease:cubic-bezier(.32,.72,0,1);
  --anatomy-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  --anatomy-lift:inset 0 1px 0 0 #ffffff0a,inset 0 0 0 1px #ffffff06,0 0 0 1px #00000026,0 1px 1px -.5px #00000014,0 3px 3px -1.5px #00000014,0 6px 6px -3px #00000014,0 12px 12px -6px #00000014;
}
.iso[data-theme="light"]{
  --anatomy-page:#f2f2ef;--anatomy-card:#fbfbfa;--anatomy-text:#57575a;--anatomy-strong:#18181a;--anatomy-muted:#8c8c90;
  --anatomy-paper:#fbfbfa;--anatomy-face:#ffffff;--anatomy-top:#ffffff;
  --anatomy-shade-0:#dcdcd9;--anatomy-shade-1:#e5e5e2;--anatomy-shade-2:#ededea;--anatomy-shade-3:#f4f4f1;
  --anatomy-lit-top:#ffffff;--anatomy-lit-shade:#eeeeeb;
  --anatomy-hi:#26262a;--anatomy-mid:#8a8a8f;--anatomy-lo:#b4b4b8;--anatomy-faint:#d4d4d6;--anatomy-lit:#000000;
  --anatomy-halo:#8a8a80;--anatomy-halo-opacity:.32;--anatomy-dot:#3a3a3e;
  --anatomy-red:#e5484d;--anatomy-green:#30a46c;--anatomy-blue:#3e63dd;
  --anatomy-lift:inset 0 1px 0 0 #ffffff,0 0 0 1px #0000000f,0 1px 1px -.5px #0000000a,0 3px 3px -1.5px #0000000a,0 6px 6px -3px #0000000a,0 12px 12px -6px #0000000a;
}
.iso-svg{display:block;width:100%;height:auto;overflow:visible}
.iso-svg path,.iso-svg circle{vector-effect:non-scaling-stroke}
.iso-fill{fill:var(--anatomy-paper);stroke:none}
.iso-solid[data-lit]>.iso-fill{fill:var(--anatomy-face)}
.iso-shade,.iso-top{stroke:none}
.iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)}
.iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)}
.iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)}
.iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)}
.iso-top{fill:var(--anatomy-top)}
.iso-solid[data-lit]>.iso-top{fill:var(--anatomy-lit-top)}
.iso-solid[data-lit]>.iso-shade{fill:var(--anatomy-lit-shade)}
.iso-solid[data-seamless]>.iso-shade,.iso-solid[data-seamless]>.iso-top{stroke-width:.4;stroke-linejoin:round}
.iso-solid[data-seamless]>.iso-shade[data-shade="0"]{stroke:var(--anatomy-shade-0)}
.iso-solid[data-seamless]>.iso-shade[data-shade="1"]{stroke:var(--anatomy-shade-1)}
.iso-solid[data-seamless]>.iso-shade[data-shade="2"]{stroke:var(--anatomy-shade-2)}
.iso-solid[data-seamless]>.iso-shade[data-shade="3"]{stroke:var(--anatomy-shade-3)}
.iso-solid[data-seamless]>.iso-top{stroke:var(--anatomy-top)}
.iso-solid[data-seamless][data-lit]>.iso-top{stroke:var(--anatomy-lit-top)}
.iso-solid[data-seamless][data-lit]>.iso-shade{stroke:var(--anatomy-lit-shade)}
.iso-solid[data-lit]>.iso-edge{stroke:var(--anatomy-lit)}
.iso-halo{fill:var(--anatomy-halo);stroke:none;filter:blur(9px);opacity:var(--anatomy-halo-opacity);transform:translateY(3px)}
.iso-line{fill:none;stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight);stroke-linecap:round;stroke-linejoin:round}
.iso-line[data-tone="hi"]{stroke:var(--anatomy-hi)}
.iso-line[data-tone="mid"]{stroke:var(--anatomy-mid)}
.iso-line[data-tone="lo"]{stroke:var(--anatomy-lo)}
.iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)}
.iso-line[data-tone="lit"]{stroke:var(--anatomy-lit)}
.iso-line[data-tone="red"]{stroke:var(--anatomy-red)}
.iso-line[data-tone="green"]{stroke:var(--anatomy-green)}
.iso-line[data-tone="blue"]{stroke:var(--anatomy-blue)}
.iso-line[data-dotted]{stroke-dasharray:.01 3.5;stroke-width:1}
.iso-line[data-dashed]{stroke-dasharray:3 3}
.iso-line[data-flow]{stroke-dasharray:2 6}
.iso-wire-end{fill:var(--anatomy-paper);stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight)}
.iso-wire[data-tone="hi"] .iso-wire-end{stroke:var(--anatomy-hi)}
.iso-wire[data-tone="mid"] .iso-wire-end{stroke:var(--anatomy-mid)}
.iso-wire[data-tone="faint"] .iso-wire-end{stroke:var(--anatomy-faint)}
.iso-wire[data-tone="lit"] .iso-wire-end{stroke:var(--anatomy-lit)}
.iso-dots circle{fill:var(--anatomy-dot);stroke:none;opacity:.32}
.iso-dots[data-tone="hi"] circle{opacity:.85}
.iso-dots[data-tone="mid"] circle{opacity:.45}
.iso-dots[data-tone="lo"] circle{opacity:.22}
.iso-dots[data-tone="faint"] circle{opacity:.12}
.iso-face-text{fill:var(--anatomy-mid);font-family:var(--anatomy-mono);letter-spacing:.04em}
.iso-face-text[data-tone="hi"]{fill:var(--anatomy-hi)}
.iso-face-text[data-tone="lo"]{fill:var(--anatomy-lo)}
.iso-face-text[data-tone="faint"]{fill:var(--anatomy-faint)}
.tb-body{fill:var(--anatomy-shade-2);stroke:none}
.tb-shine{fill:var(--anatomy-top);stroke:none}
.tb-shade{fill:var(--anatomy-shade-0);stroke:none}
.tb-ring{fill:none;stroke:var(--anatomy-lo);stroke-width:.45;stroke-linecap:butt;stroke-linejoin:round}
.tb-ring[data-tone="mid"]{stroke:var(--anatomy-mid)}
.tb-ring[data-tone="faint"]{stroke:var(--anatomy-faint)}
.tb-ring[data-tone="hi"]{stroke:var(--anatomy-hi)}
@keyframes iso-flow{to{stroke-dashoffset:-32}}
@keyframes iso-pulse{0%,100%{opacity:.18}12%{opacity:1}40%{opacity:.18}}
.iso-line[data-flow]{animation:iso-flow 1600ms linear infinite}
.iso-dots circle[data-pulse]{animation:iso-pulse 1600ms steps(1,end) infinite;animation-delay:var(--pulse-at,0ms)}
.iso-figure{margin:32px 0 36px}
.iso-plate{position:relative;padding:52px 24px;border-radius:0;background:var(--anatomy-card);box-shadow:0 0 0 1px var(--anatomy-mid);--anatomy-paper:var(--anatomy-card)}
.iso-plate-corners{position:absolute;inset:18px 20px;pointer-events:none}
.iso-plate-corner{position:absolute;color:var(--anatomy-muted);font-family:var(--anatomy-mono);font-size:10.5px;line-height:1;letter-spacing:.04em;white-space:nowrap}
.iso-plate-corner[data-corner="fig"]{top:0;left:0;color:var(--anatomy-text)}
.iso-plate-corner[data-corner="title"]{top:0;right:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="hint"]{bottom:0;left:0;text-transform:uppercase;letter-spacing:.1em}
.iso-plate-corner[data-corner="readout"]{right:0;bottom:0;color:var(--anatomy-text);font-variant-numeric:tabular-nums}
.iso-plate .iso-svg{margin:0 auto}
.iso-legend{display:grid;gap:8px;margin-top:12px;color:var(--anatomy-muted);font-size:13px;line-height:1.55}
.iso-keys{display:flex;flex-wrap:wrap;gap:6px 18px}
.iso-key{display:inline-flex;align-items:center;gap:7px}
.iso-swatch{width:16px;height:10px;flex:none;overflow:visible}
.iso-swatch path{fill:none;stroke:var(--anatomy-hi);stroke-width:1;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
.iso-swatch[data-mark="raised"] path,.iso-swatch[data-mark="flat"] path{stroke:var(--anatomy-mid)}
.iso-swatch[data-mark="lit"] path{stroke:var(--anatomy-lit)}
.iso-swatch[data-mark="dim"] path{stroke:var(--anatomy-lo)}
.iso-swatch[data-mark="dotted"] path{stroke-dasharray:.01 3;stroke-width:1.4}
.iso-swatch[data-mark="dashed"] path{stroke-dasharray:3 2.5}
.iso-swatch[data-mark="red"] path{stroke:var(--anatomy-red)}
.iso-swatch[data-mark="green"] path{stroke:var(--anatomy-green)}
.iso-swatch[data-mark="blue"] path{stroke:var(--anatomy-blue)}
.iso-swatch-crease{stroke:var(--anatomy-lo)!important}
@media (max-width:560px){.iso-plate{padding:48px 12px 70px}.iso-plate-corner[data-corner="hint"]{bottom:18px}}
@media (prefers-reduced-motion:reduce){.iso-line[data-flow],.iso-dots circle[data-pulse]{animation:none}}
`;
```

## kit/iso.css

```

```

## kit/lathe.mjs

```js
import { cameraOf, iso, pathOf } from "./iso-kit.mjs";

const TAU = Math.PI * 2;
const LIGHT_UP = 1.2;
const BANDS = [0, 0.2, 0.42, 0.62];

export const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
export const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
export const mul3 = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
export const len3 = (a) => Math.hypot(a[0], a[1], a[2]);
export const unit3 = (a) => {
  const length = len3(a) || 1;
  return [a[0] / length, a[1] / length, a[2] / length];
};
export const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
export const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];

export function viewOf(P) {
  const { sinA, cosA, sinE, cosE } = cameraOf(P);
  return [cosA * cosE, sinA * cosE, sinE];
}

export function lightOf(P) {
  const { sinA, cosA } = cameraOf(P);
  return unit3([-sinA, cosA, LIGHT_UP]);
}

export const scaleOf = (P) => cameraOf(P).k;

export function bandOf(score) {
  let tone = 0;
  while (tone < BANDS.length && score >= BANDS[tone]) tone++;
  return tone;
}

export const toneOf = (normal, P) => bandOf(dot3(unit3(normal), lightOf(P)));

export function frameAlong(o, a) {
  const axis = unit3(a);
  const helper = Math.abs(axis[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
  const u = unit3(cross3(helper, axis));
  const v = cross3(axis, u);
  return { o, a: axis, u, v };
}

export const frameOf = (o, a, u, v) => ({ o, a, u, v });

export function pointOf(F, s, r, angle) {
  const c = Math.cos(angle);
  const n = Math.sin(angle);
  return [
    F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]),
    F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]),
    F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]),
  ];
}

export const radialOf = (F, angle) => {
  const c = Math.cos(angle);
  const n = Math.sin(angle);
  return [c * F.u[0] + n * F.v[0], c * F.u[1] + n * F.v[1], c * F.u[2] + n * F.v[2]];
};

export const tangentOf = (F, angle) => {
  const c = Math.cos(angle);
  const n = Math.sin(angle);
  return [-n * F.u[0] + c * F.v[0], -n * F.u[1] + c * F.v[1], -n * F.u[2] + c * F.v[2]];
};

export const angleToward = (F, direction) => Math.atan2(dot3(direction, F.v), dot3(direction, F.u));

function hullTagged(points) {
  const sorted = [...points].sort((a, b) => a.p[0] - b.p[0] || a.p[1] - b.p[1]);
  if (sorted.length < 3) return sorted;
  const cross = (o, a, b) => (a.p[0] - o.p[0]) * (b.p[1] - o.p[1]) - (a.p[1] - o.p[1]) * (b.p[0] - o.p[0]);
  const lower = [];
  for (const point of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 1e-9) lower.pop();
    lower.push(point);
  }
  const upper = [];
  for (let index = sorted.length - 1; index >= 0; index--) {
    const point = sorted[index];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 1e-9) upper.pop();
    upper.push(point);
  }
  return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}

export const hullOf = (points) => hullTagged(points.map((p) => ({ p }))).map(({ p }) => p);

export function runsOf(flags) {
  const count = flags.length;
  const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
  if (start < 0) return flags[0] ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
  const runs = [];
  let run = null;
  for (let step = 0; step < count; step++) {
    const index = (start + step) % count;
    if (flags[index]) {
      if (!run) run = [index];
      run.push((index + 1) % count);
    } else if (run) {
      runs.push(run);
      run = null;
    }
  }
  if (run) runs.push(run);
  return runs;
}

function groupsOf(keys) {
  const count = keys.length;
  const out = [];
  const start = keys.findIndex((key, index) => key !== keys[(index + count - 1) % count]);
  if (start < 0) {
    if (keys[0] !== null) out.push({ key: keys[0], run: Array.from({ length: count + 1 }, (_, index) => index % count) });
    return out;
  }
  let current = null;
  for (let step = 0; step < count; step++) {
    const index = (start + step) % count;
    const key = keys[index];
    if (current && current.key === key) {
      current.run.push((index + 1) % count);
      continue;
    }
    if (current && current.key !== null) out.push(current);
    current = { key, run: [index, (index + 1) % count] };
  }
  if (current && current.key !== null) out.push(current);
  return out;
}

export const stepsFor = (radius, least = 16, most = 112, scale = 0) => Math.max(least, Math.min(most, scale ? Math.ceil((TAU * radius * scale) / 3) : Math.round((radius * 1.3) / 4) * 4));

function chains(edges) {
  const out = [];
  let current = null;
  for (const [a, b, keep] of edges) {
    if (!keep) {
      if (current) out.push(current);
      current = null;
      continue;
    }
    if (!current) current = [a];
    current.push(b);
  }
  if (current) out.push(current);
  if (out.length > 1 && edges[0][2] && edges[edges.length - 1][2]) {
    const last = out.pop();
    out[0] = [...last, ...out[0].slice(1)];
  }
  return out;
}

function labelledArcs(nr, na, view, light, gr = nr, ga = na) {
  const cuts = [];
  if (Math.abs(gr) > 1e-9) {
    const visibleCut = -(ga * view.a) / (gr * view.flat);
    if (visibleCut > -1 && visibleCut < 1) {
      const d = Math.acos(visibleCut);
      cuts.push(view.phi - d, view.phi + d);
    }
  }
  if (Math.abs(nr) > 1e-9) {
    for (const threshold of BANDS) {
      const c = (threshold - na * light.a) / (nr * light.flat);
      if (c > -1 && c < 1) {
        const d = Math.acos(c);
        cuts.push(light.phi - d, light.phi + d);
      }
    }
  }
  const wrap = (angle) => ((angle % TAU) + TAU) % TAU;
  const sorted = [...new Set(cuts.map((angle) => Math.round(wrap(angle) * 1e9) / 1e9))].sort((a, b) => a - b);
  const label = (angle) => {
    const facing = gr * view.flat * Math.cos(angle - view.phi) + ga * view.a;
    if (facing <= 1e-9) return -1;
    return bandOf(nr * light.flat * Math.cos(angle - light.phi) + na * light.a);
  };
  if (!sorted.length) {
    const tone = label(0);
    return tone < 0 ? [] : [{ tone, from: 0, to: TAU, full: true }];
  }
  const arcs = sorted.map((from, index) => {
    const to = index + 1 < sorted.length ? sorted[index + 1] : sorted[0] + TAU;
    return { tone: label((from + to) / 2), from, to };
  });
  const merged = [];
  for (const arc of arcs) {
    const last = merged[merged.length - 1];
    if (last && last.tone === arc.tone && Math.abs(last.to - arc.from) < 1e-9) last.to = arc.to;
    else merged.push({ ...arc });
  }
  if (merged.length > 1 && merged[0].tone === merged[merged.length - 1].tone && Math.abs(merged[merged.length - 1].to - TAU - merged[0].from) < 1e-9) {
    const last = merged.pop();
    merged[0].from = last.from - TAU;
  }
  return merged.filter((arc) => arc.tone >= 0);
}

function hermiteSlopes(run) {
  const n = run.length - 1;
  const h = Array.from({ length: n }, (_, j) => run[j + 1][0] - run[j][0]);
  const delta = h.map((value, j) => (run[j + 1][1] - run[j][1]) / value);
  const d = new Array(n + 1).fill(0);
  const end = (h0, h1, d0, d1) => {
    let value = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
    if (Math.sign(value) !== Math.sign(d0)) value = 0;
    else if (Math.sign(d0) !== Math.sign(d1) && Math.abs(value) > Math.abs(3 * d0)) value = 3 * d0;
    return value;
  };
  if (n === 1) d[0] = d[1] = delta[0];
  else {
    d[0] = end(h[0], h[1], delta[0], delta[1]);
    d[n] = end(h[n - 1], h[n - 2], delta[n - 1], delta[n - 2]);
  }
  for (let k = 1; k < n; k++) {
    if (delta[k - 1] * delta[k] <= 0) continue;
    const w1 = 2 * h[k] + h[k - 1];
    const w2 = h[k] + 2 * h[k - 1];
    d[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
  }
  return d;
}

export function curveRuns(meridian) {
  const m = meridian.length;
  const segments = new Array(m - 1).fill(null);
  let start = 0;
  while (start < m - 1) {
    let end = start + 1;
    while (end < m - 1 && meridian[end][2] && Math.abs(meridian[end + 1][0] - meridian[end][0]) > 1e-9 && Math.abs(meridian[end][0] - meridian[end - 1][0]) > 1e-9) end++;
    const run = meridian.slice(start, end + 1);
    const n = run.length - 1;
    if (n >= 2 && run.every((point, j) => j === 0 || Math.abs(point[0] - run[j - 1][0]) > 1e-9)) {
      const d = hermiteSlopes(run);
      for (let j = 0; j < n; j++) {
        const [s0, r0] = run[j];
        const [s1, r1] = run[j + 1];
        const hj = s1 - s0;
        const d0 = d[j];
        const d1 = d[j + 1];
        const radius = (s) => {
          const t = (s - s0) / hj;
          return (2 * t ** 3 - 3 * t ** 2 + 1) * r0 + (t ** 3 - 2 * t ** 2 + t) * hj * d0 + (-2 * t ** 3 + 3 * t ** 2) * r1 + (t ** 3 - t ** 2) * hj * d1;
        };
        const slope = (s) => {
          const t = (s - s0) / hj;
          return ((6 * t ** 2 - 6 * t) * r0 + (6 * t - 6 * t ** 2) * r1) / hj + (3 * t ** 2 - 4 * t + 1) * d0 + (3 * t ** 2 - 2 * t) * d1;
        };
        segments[start + j] = { radius, slope };
      }
    }
    start = end;
  }
  return segments;
}

function curved(meridian, P, segments, { error = 0.2, depth = 6 } = {}) {
  const k = scaleOf(P);
  const out = [meridian[0]];
  for (let i = 0; i < meridian.length - 1; i++) {
    const curve = segments[i];
    const [s0] = meridian[i];
    const [s1] = meridian[i + 1];
    if (curve) {
      const split = (a, b, level) => {
        const mid = (a + b) / 2;
        const chord = (curve.radius(a) + curve.radius(b)) / 2;
        if (level < depth && Math.abs(curve.radius(mid) - chord) * k > error) {
          split(a, mid, level + 1);
          out.push([mid, curve.radius(mid), 1]);
          split(mid, b, level + 1);
        }
      };
      split(s0, s1, 0);
    }
    out.push(meridian[i + 1]);
  }
  return out;
}

function smoothRows(meridian, F, P, { rows = 6, slope = null, radius = null, steps = 48, error = 0.35, depth: deepest = 8, fineness = 0.1 } = {}) {
  const V = viewOf(P);
  const L = lightOf(P);
  const view = { a: dot3(F.a, V), flat: Math.hypot(dot3(F.u, V), dot3(F.v, V)), phi: Math.atan2(dot3(F.v, V), dot3(F.u, V)) };
  const light = { a: dot3(F.a, L), flat: Math.hypot(dot3(F.u, L), dot3(F.v, L)), phi: Math.atan2(dot3(F.v, L), dot3(F.u, L)) };
  const m = meridian.length;
  const faces = [];
  for (let i = 0; i < m - 1; i++) {
    const ds = meridian[i + 1][0] - meridian[i][0];
    const dr = meridian[i + 1][1] - meridian[i][1];
    const length = Math.hypot(ds, dr) || 1;
    faces.push([ds / length, -dr / length]);
  }
  const splines = slope ? null : curveRuns(meridian);
  const vertexNormal = (k, own) => {
    if (k <= 0 || k >= m - 1 || !meridian[k][2]) return own;
    const a = faces[k - 1];
    const b = faces[k];
    const x = a[0] + b[0];
    const y = a[1] + b[1];
    const length = Math.hypot(x, y) || 1;
    return [x / length, y / length];
  };
  const normalAt = (i, u) => {
    const [s0] = meridian[i];
    const [s1] = meridian[i + 1];
    const s = s0 + (s1 - s0) * u;
    const sign = Math.sign(s1 - s0) || 1;
    const g = slope ? slope(s) : splines[i] ? splines[i].slope(s) : null;
    if (g !== null) {
      const length = Math.hypot(1, g);
      return [sign / length, (-sign * g) / length];
    }
    const a = vertexNormal(i, faces[i]);
    const b = vertexNormal(i + 1, faces[i]);
    const x = a[0] + (b[0] - a[0]) * u;
    const y = a[1] + (b[1] - a[1]) * u;
    const length = Math.hypot(x, y) || 1;
    return [x / length, y / length];
  };
  const pointAt = (i, u, angle) => {
    const [s0, r0] = meridian[i];
    const [s1, r1] = meridian[i + 1];
    const s = s0 + (s1 - s0) * u;
    return iso(pointOf(F, s, radius ? radius(s) : splines && splines[i] ? splines[i].radius(s) : r0 + (r1 - r0) * u, angle), P);
  };
  const buckets = [[], [], [], [], []];
  const fine = TAU / Math.max(24, Math.min(192, steps * 1.5));
  const arcPoints = (i, u, from, to) => {
    const count = Math.max(1, Math.ceil((to - from) / fine));
    return Array.from({ length: count + 1 }, (_, index) => pointAt(i, u, from + ((to - from) * index) / count));
  };
  const centreOf = (arc, reference) => {
    let mid = (arc.from + arc.to) / 2 - reference;
    mid = ((mid % TAU) + TAU) % TAU;
    return mid;
  };
  const levelOf = (i, u) => {
    const [nr, na] = normalAt(i, u);
    const groups = new Map();
    for (const arc of labelledArcs(nr, na, view, light, faces[i][0], faces[i][1])) {
      if (!groups.has(arc.tone)) groups.set(arc.tone, []);
      groups.get(arc.tone).push(arc);
    }
    for (const list of groups.values()) list.sort((x, y) => centreOf(x, light.phi) - centreOf(y, light.phi));
    return { i, u, groups };
  };
  const same = (x, y) => {
    if (x.groups.size !== y.groups.size) return false;
    for (const [tone, list] of x.groups) if ((y.groups.get(tone) ?? []).length !== list.length) return false;
    return true;
  };
  const align = (reference, arc) => {
    const out = { ...arc };
    const centre = (reference.from + reference.to) / 2;
    while ((out.from + out.to) / 2 - centre > Math.PI) {
      out.from -= TAU;
      out.to -= TAU;
    }
    while (centre - (out.from + out.to) / 2 > Math.PI) {
      out.from += TAU;
      out.to += TAU;
    }
    return out;
  };
  const strayOf = (i, a, mid, b) => {
    let worst = 0;
    for (const [tone, list] of mid.groups) {
      list.forEach((arc, index) => {
        const lo = align(arc, a.groups.get(tone)[index]);
        const hi = align(arc, b.groups.get(tone)[index]);
        for (const key of ["from", "to"]) {
          const p = pointAt(i, mid.u, arc[key]);
          const p0 = pointAt(i, a.u, lo[key]);
          const p1 = pointAt(i, b.u, hi[key]);
          worst = Math.max(worst, Math.hypot(p[0] - (p0[0] + p1[0]) / 2, p[1] - (p0[1] + p1[1]) / 2));
        }
      });
    }
    return worst;
  };
  const sheet = (tone, levels) => {
    levels[0].groups.get(tone).forEach((arc, index) => {
      const chain = [arc];
      for (let k = 1; k < levels.length; k++) chain.push(align(chain[k - 1], levels[k].groups.get(tone)[index]));
      const last = levels.length - 1;
      const points = [...arcPoints(levels[0].i, levels[0].u, chain[0].from, chain[0].to)];
      for (let k = 1; k < last; k++) points.push(pointAt(levels[k].i, levels[k].u, chain[k].to));
      points.push(...arcPoints(levels[last].i, levels[last].u, chain[last].from, chain[last].to).reverse());
      for (let k = last - 1; k >= 1; k--) points.push(pointAt(levels[k].i, levels[k].u, chain[k].from));
      buckets[tone].push(pathOf(oriented(points), true));
    });
  };
  const overlap = (x, y) => Math.min(x.to, y.to) - Math.max(x.from, y.from);
  const strip = (tone, a, b) => {
    const below = a.groups.get(tone) ?? [];
    const above = (b.groups.get(tone) ?? []).map((arc) => ({ arc, used: false }));
    const quad = (lo, hi) => {
      const points = [...arcPoints(a.i, a.u, lo.from, lo.to), ...arcPoints(b.i, b.u, hi.from, hi.to).reverse()];
      buckets[tone].push(pathOf(oriented(points), true));
    };
    for (const arc of below) {
      let best = null;
      let score = -Infinity;
      for (const entry of above) {
        if (entry.used) continue;
        const aligned = align(arc, entry.arc);
        const value = overlap(arc, aligned);
        if (value > score) (score = value), (best = entry);
      }
      if (best && score > -0.05) {
        best.used = true;
        quad(arc, align(arc, best.arc));
      } else {
        const centre = (arc.from + arc.to) / 2;
        quad(arc, { from: centre, to: centre });
      }
    }
    for (const entry of above) {
      if (entry.used) continue;
      const centre = (entry.arc.from + entry.arc.to) / 2;
      quad({ from: centre, to: centre }, entry.arc);
    }
  };
  const k = scaleOf(P);
  let run = [];
  const flush = () => {
    const tones = [...new Set(run.flatMap((level) => [...level.groups.keys()]))].sort((x, y) => x - y);
    for (const tone of tones) {
      const countOf = (level) => (level.groups.get(tone) ?? []).length;
      let start = 0;
      for (let at = 1; at <= run.length; at++) {
        if (at < run.length && countOf(run[at - 1]) === countOf(run[at])) continue;
        if (at - 1 > start && countOf(run[start])) sheet(tone, run.slice(start, at));
        if (at < run.length) strip(tone, run[at - 1], run[at]);
        start = at;
      }
    }
    run = [];
  };
  for (let i = 0; i < m - 1; i++) {
    const ds = meridian[i + 1][0] - meridian[i][0];
    if (Math.abs(ds) < 1e-9) {
      flush();
      continue;
    }
    const count = Math.max(1, rows);
    const reach = (Math.hypot(ds, meridian[i + 1][1] - meridian[i][1]) * k) / count;
    const limit = Math.max(1, Math.min(deepest, Math.ceil(Math.log2(Math.max(2, reach / fineness)))));
    const coarse = Array.from({ length: count + 1 }, (_, index) => levelOf(i, index / count));
    run.push(coarse[0]);
    const refine = (a, b, depth) => {
      const mid = levelOf(i, (a.u + b.u) / 2);
      const split = depth < limit && (!same(a, b) || !same(a, mid) || (depth < 6 && strayOf(i, a, mid, b) > error));
      if (!split) {
        run.push(b);
        return;
      }
      refine(a, mid, depth + 1);
      refine(mid, b, depth + 1);
    };
    for (let index = 1; index <= count; index++) refine(coarse[index - 1], coarse[index], 0);
  }
  flush();
  return buckets;
}

function sideOf(meridian) {
  const m = meridian.length;
  let first = 0;
  let last = m - 1;
  if (meridian[0][1] <= 1e-9) first = 1;
  if (meridian[m - 1][1] <= 1e-9) last = m - 2;
  const side = meridian.slice(first, last + 1);
  for (let index = 1; index < side.length; index++) if (side[index][0] - side[index - 1][0] <= 1e-9) return null;
  return side.length >= 3 ? side : null;
}

function surfaceOf(side, F, P, rims, { samples = 96, slope = null } = {}) {
  const V = viewOf(P);
  const aV = dot3(F.a, V);
  const facing = Math.atan2(dot3(F.v, V), dot3(F.u, V));
  const [left, right] = silhouettePoints(side, F, P, { samples, slope });
  if (left.length <= samples || right.length <= samples) return null;
  const s0 = side[0][0];
  const s1 = side[side.length - 1][0];
  const angleOf = (point) => angleToward(F, sub3(point, add3(F.o, mul3(F.a, dot3(sub3(point, F.o), F.a)))));
  const wrap = (angle) => ((angle % TAU) + TAU) % TAU;
  const arc = (s, r, from, to, through) => {
    let sweep = wrap(to - from);
    if (wrap(through - from) > sweep) sweep -= TAU;
    const count = Math.max(4, Math.ceil(Math.abs(sweep) / (TAU / 96)), Math.ceil((Math.abs(sweep) * r * scaleOf(P)) / 1.5));
    return Array.from({ length: count + 1 }, (_, index) => iso(pointOf(F, s, r, from + (sweep * index) / count), P));
  };
  const r0 = side[0][1];
  const r1 = side[side.length - 1][1];
  const bottom = -aV > 0 ? facing + Math.PI : facing;
  const top = aV > 0 ? facing + Math.PI : facing;
  const L = left.map((point) => iso(point, P));
  const R = right.map((point) => iso(point, P));
  const topArc = arc(s1, r1, angleOf(left[left.length - 1]), angleOf(right[right.length - 1]), top);
  const bottomArc = arc(s0, r0, angleOf(right[0]), angleOf(left[0]), bottom);
  const keepFirst = rims === "all" || rims === "first" || rims === "ends";
  const keepLast = rims === "all" || rims === "last" || rims === "ends";
  const fill = pathOf([...L, ...topArc, ...[...R].reverse(), ...bottomArc], true);
  const outline = [pathOf(L), pathOf(R), keepLast ? pathOf(topArc) : "", keepFirst ? pathOf(bottomArc) : ""].join("");
  const hull = hullOf([...arc(s0, r0, 0, TAU - 1e-6, Math.PI), ...arc(s1, r1, 0, TAU - 1e-6, Math.PI), ...L, ...R]);
  let gap = 0;
  for (const p of [...L, ...R]) {
    let best = Infinity;
    for (let index = 0; index < hull.length; index++) {
      const a = hull[index];
      const b = hull[(index + 1) % hull.length];
      const dx = b[0] - a[0];
      const dy = b[1] - a[1];
      const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1)));
      best = Math.min(best, Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t));
    }
    gap = Math.max(gap, best);
  }
  return { fill, outline, gap };
}

export const softOf = (meridian) => meridian.some((point) => Boolean(point[2]));

export function lathe(meridian, F, P, { steps = stepsFor(Math.max(...meridian.map(([, r]) => r)), 32, 160, scaleOf(P)), fill = "auto", hole = null, bevel = 0, rims = "all", smooth: asked = "auto", waist = 0.5 } = {}) {
  const smooth = asked === "auto" ? softOf(meridian) : asked;
  const V = viewOf(P);
  const L = lightOf(P);
  const aV = dot3(F.a, V);
  const uV = dot3(F.u, V);
  const vV = dot3(F.v, V);
  const aL = dot3(F.a, L);
  const uL = dot3(F.u, L);
  const vL = dot3(F.v, L);
  const n = steps;
  const m = meridian.length;
  const rings = meridian.map(([s, r]) => Array.from({ length: n }, (_, j) => iso(pointOf(F, s, r, (j / n) * TAU), P)));
  const midC = [];
  const midS = [];
  for (let j = 0; j < n; j++) {
    const t = ((j + 0.5) / n) * TAU;
    midC.push(Math.cos(t));
    midS.push(Math.sin(t));
  }
  const seen = [];
  const tones = [];
  for (let i = 0; i < m - 1; i++) {
    const ds = meridian[i + 1][0] - meridian[i][0];
    const dr = meridian[i + 1][1] - meridian[i][1];
    const length = Math.hypot(ds, dr);
    const row = [];
    const toneRow = [];
    for (let j = 0; j < n; j++) {
      if (length < 1e-9) {
        row.push(false);
        toneRow.push(0);
        continue;
      }
      const facing = ds * (midC[j] * uV + midS[j] * vV) - dr * aV;
      const lit = (ds * (midC[j] * uL + midS[j] * vL) - dr * aL) / length;
      row.push(facing > 1e-6 * length);
      toneRow.push(bandOf(lit));
    }
    seen.push(row);
    tones.push(toneRow);
  }
  const buckets = [[], [], [], [], []];
  const all = [];
  const smoothed = smooth ? smoothRows(meridian, F, P, { steps, ...(smooth === true ? {} : smooth) }) : null;
  if (smoothed) smoothed.forEach((list, tone) => list.forEach((d) => (buckets[tone].push(d), all.push(d))));
  for (let i = 0; i < m - 1; i++) {
    if (smoothed && Math.abs(meridian[i + 1][0] - meridian[i][0]) > 1e-9) continue;
    const keys = seen[i].map((visible, j) => (visible ? tones[i][j] : null));
    for (const { key, run } of groupsOf(keys)) {
      const a = run.map((j) => rings[i][j]);
      const b = run.map((j) => rings[i + 1][j]).reverse();
      const d = pathOf(oriented([...a, ...b]), true);
      buckets[key].push(d);
      all.push(d);
    }
  }
  const phi = Math.atan2(vV, uV);
  const flatV = Math.hypot(uV, vV);
  const rangeOf = (i) => {
    const ds = meridian[i + 1][0] - meridian[i][0];
    const dr = meridian[i + 1][1] - meridian[i][1];
    if (Math.hypot(ds, dr) < 1e-9) return null;
    if (Math.abs(ds) < 1e-9 || flatV < 1e-9) return -dr * aV > 1e-9 ? [-1, 1] : null;
    const c = (dr * aV) / (ds * flatV);
    return ds > 0 ? [Math.max(-1, c), 1] : [-1, Math.min(1, c)];
  };
  const arcsOf = (ranges) => {
    if (ranges.some((range) => !range)) return [];
    const lo = Math.max(...ranges.map(([x]) => x));
    const hi = Math.min(...ranges.map(([, x]) => x));
    if (lo >= hi - 1e-9) return [];
    const near = Math.acos(Math.min(1, hi));
    const far = Math.acos(Math.max(-1, lo));
    if (hi >= 1 && lo <= -1) return [[phi, phi + TAU]];
    if (hi >= 1) return [[phi - far, phi + far]];
    if (lo <= -1) return [[phi + near, phi + TAU - near]];
    return [[phi + near, phi + far], [phi - far, phi - near]];
  };
  const along = (s, r, [from, to]) => {
    const count = Math.max(2, Math.ceil(((to - from) * Math.max(r, 1e-6) * scaleOf(P)) / 1.5));
    return Array.from({ length: count + 1 }, (_, j) => iso(pointOf(F, s, r, from + ((to - from) * j) / count), P));
  };
  const creases = [];
  for (let i = 1; i < m - 1; i++) {
    if (meridian[i][2] || meridian[i][1] <= 1e-9) continue;
    for (const arc of arcsOf([rangeOf(i - 1), rangeOf(i)])) creases.push(pathOf(along(meridian[i][0], meridian[i][1], arc)));
  }
  let bevelPath = "";
  if (bevel > 0 && m > 2) {
    const ends = [];
    if (meridian[0][1] <= 1e-9) ends.push([0, 1, 1]);
    if (meridian[m - 1][1] <= 1e-9) ends.push([m - 2, m - 2, m - 3]);
    for (const [cap, rim, side] of ends) {
      const [s, r] = meridian[rim];
      for (const arc of arcsOf([rangeOf(cap), rangeOf(side)])) {
        const inset = along(s, Math.max(0, r - bevel), arc);
        const edge = along(s, r, arc);
        bevelPath += pathOf([edge[0], ...inset.slice(1, -1), edge[edge.length - 1]]);
      }
    }
  }
  const { fill: fillPath, outline } = outlineOf(meridian, F, P, { steps: n, fill, hole, rims, smooth, waist, all });
  return {
    fill: fillPath,
    outline,
    crease: creases.join(""),
    top: buckets[4].join(""),
    shades: buckets.slice(0, 4).map((list) => list.join("")),
    bevel: bevelPath || undefined,
    seamless: Boolean(smoothed),
  };
}

function shapeOf(meridian, P, smooth) {
  if (smooth && smooth.radius) {
    const segments = meridian.slice(0, -1).map(([s0], index) => (Math.abs(meridian[index + 1][0] - s0) > 1e-9 ? { radius: smooth.radius, slope: smooth.slope } : null));
    return curved(meridian, P, segments);
  }
  const curves = smooth && !smooth.slope ? curveRuns(meridian) : null;
  return curves && curves.some(Boolean) ? curved(meridian, P, curves) : meridian;
}

export function outlineOf(meridian, F, P, { steps = stepsFor(Math.max(...meridian.map(([, r]) => r)), 32, 160, scaleOf(P)), fill = "auto", hole = null, rims = "all", smooth: asked = "auto", waist = 0.5, all = null } = {}) {
  const smooth = asked === "auto" ? softOf(meridian) : asked;
  const n = steps;
  let fillPath;
  let outline;
  const shape = shapeOf(meridian, P, smooth);
  const side = fill === "auto" || fill === "surface" ? sideOf(shape) : null;
  const surface = side && !hole ? surfaceOf(side, F, P, rims, { slope: smooth && smooth.slope ? smooth.slope : null }) : null;
  if (surface && (fill === "surface" || surface.gap > waist)) {
    fillPath = surface.fill;
    outline = surface.outline;
  } else if (fill === "hull" || fill === "auto" || fill === "surface") {
    const most = Math.max(...shape.map(([, r]) => r));
    const around = Math.min(320, Math.max(n, Math.ceil((TAU * most * scaleOf(P)) / 1.5)));
    const tagged = [];
    shape.forEach(([s, r], index) => {
      for (let j = 0; j < around; j++) tagged.push({ p: iso(pointOf(F, s, r, (j / around) * TAU), P), ring: index });
    });
    const hull = hullTagged(tagged);
    const holePath = hole ? pathOf(Array.from({ length: around }, (_, j) => iso(pointOf(F, hole[0], hole[1], (j / around) * TAU), P)), true) : "";
    fillPath = pathOf(hull.map(({ p }) => p), true) + holePath;
    if (rims === "all") {
      outline = pathOf(hull.map(({ p }) => p), true) + holePath;
    } else {
      const keepFirst = rims === "first" || rims === "ends";
      const keepLast = rims === "last" || rims === "ends";
      const edges = hull.map((a, index) => {
        const b = hull[(index + 1) % hull.length];
        const same = a.ring === b.ring;
        const keep = !same || (a.ring !== 0 && a.ring !== shape.length - 1) || (keepFirst && a.ring === 0) || (keepLast && a.ring === shape.length - 1);
        return [a.p, b.p, keep];
      });
      outline = chains(edges).map((points) => pathOf(points)).join("") + holePath;
    }
  } else {
    fillPath = all ? all.join("") : (({ shades, top }) => [...shades, top].join(""))(lathe(meridian, F, P, { steps: n, fill: "auto", hole, rims, smooth, waist }));
    outline = "";
  }
  return { fill: fillPath, outline };
}

const signedArea = (points) => points.reduce((sum, [x, y], index) => {
  const [nx, ny] = points[(index + 1) % points.length];
  return sum + x * ny - nx * y;
}, 0);

const oriented = (points) => (signedArea(points) < 0 ? [...points].reverse() : points);

export const saddleStart = (R, a, angle) => Math.sqrt(Math.max(0, R * R - a * a * Math.cos(angle) ** 2));

export function saddleOf(F, a, start, s1, P, { steps = stepsFor(a, 24) } = {}) {
  const V = viewOf(P);
  const L = lightOf(P);
  const n = steps;
  const angle = (j) => (j / n) * TAU;
  const from = typeof start === "function" ? start : (t) => saddleStart(start, a, t);
  const low = Array.from({ length: n }, (_, j) => iso(pointOf(F, from(angle(j)), a, angle(j)), P));
  const high = Array.from({ length: n }, (_, j) => iso(pointOf(F, s1, a, angle(j)), P));
  const seen = [];
  const tones = [];
  for (let j = 0; j < n; j++) {
    const normal = radialOf(F, angle(j + 0.5));
    seen.push(dot3(normal, V) > 1e-6);
    tones.push(bandOf(dot3(normal, L)));
  }
  const buckets = [[], [], [], [], []];
  const fills = [];
  const keys = seen.map((visible, j) => (visible ? tones[j] : null));
  for (const { key, run } of groupsOf(keys)) {
    const d = pathOf(oriented([...run.map((j) => low[j]), ...run.map((j) => high[j]).reverse()]), true);
    buckets[key].push(d);
    fills.push(d);
  }
  const capSeen = dot3(F.a, V) > 1e-6;
  if (capSeen) {
    const d = pathOf(oriented(high), true);
    buckets[bandOf(dot3(F.a, L))].push(d);
    fills.push(d);
  }
  const front = runsOf(seen);
  const back = runsOf(seen.map((visible) => !visible));
  const edges = [];
  for (const run of front) {
    edges.push(pathOf(run.map((j) => low[j])));
    edges.push(pathOf([low[run[0]], high[run[0]]]), pathOf([low[run[run.length - 1]], high[run[run.length - 1]]]));
  }
  for (const run of capSeen ? back : front) edges.push(pathOf(run.map((j) => high[j])));
  return {
    fill: fills.join(""),
    outline: edges.join(""),
    crease: capSeen ? front.map((run) => pathOf(run.map((j) => high[j]))).join("") : "",
    top: buckets[4].join(""),
    shades: buckets.slice(0, 4).map((list) => list.join("")),
    seamless: true,
  };
}

export function prismOf(F, polygon, s0, s1, P) {
  const V = viewOf(P);
  const L = lightOf(P);
  const ring = oriented(polygon.map(([x, y]) => [x, y]));
  const n = ring.length;
  const at = (s, [x, y]) => [F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y, F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y, F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y];
  const low = ring.map((point) => iso(at(s0, point), P));
  const high = ring.map((point) => iso(at(s1, point), P));
  const buckets = [[], [], [], [], []];
  const seen = [];
  for (let j = 0; j < n; j++) {
    const [x0, y0] = ring[j];
    const [x1, y1] = ring[(j + 1) % n];
    const normal = unit3(add3(mul3(F.u, y1 - y0), mul3(F.v, -(x1 - x0))));
    const visible = dot3(normal, V) > 1e-6;
    seen.push(visible);
    if (visible) buckets[bandOf(dot3(normal, L))].push(pathOf([low[j], low[(j + 1) % n], high[(j + 1) % n], high[j]], true));
  }
  const ends = [[s0, low, mul3(F.a, -1)], [s1, high, F.a]];
  const capSeen = ends.map(([, , normal]) => dot3(normal, V) > 1e-6);
  ends.forEach(([, points, normal], index) => capSeen[index] && buckets[bandOf(dot3(normal, L))].push(pathOf(points, true)));
  const hull = hullOf([...low, ...high]);
  const creases = [];
  for (let j = 0; j < n; j++) {
    const prev = (j - 1 + n) % n;
    const a = [ring[j][0] - ring[prev][0], ring[j][1] - ring[prev][1]];
    const b = [ring[(j + 1) % n][0] - ring[j][0], ring[(j + 1) % n][1] - ring[j][1]];
    const sharp = (a[0] * b[0] + a[1] * b[1]) / (Math.hypot(a[0], a[1]) * Math.hypot(b[0], b[1]) || 1) < Math.cos(0.35);
    if (sharp && seen[j] && seen[prev]) creases.push(pathOf([low[j], high[j]]));
    ends.forEach(([, points], index) => capSeen[index] && seen[j] && creases.push(pathOf([points[j], points[(j + 1) % n]])));
  }
  return { fill: pathOf(hull, true), outline: pathOf(hull, true), crease: creases.join(""), top: buckets[4].join(""), shades: buckets.slice(0, 4).map((list) => list.join("")) };
}

export function stadium(r0, r1, offset, steps = 32) {
  const circle = (cx, r) => Array.from({ length: steps }, (_, index) => [cx + r * Math.cos((index / steps) * TAU), r * Math.sin((index / steps) * TAU)]);
  return hullOf([...circle(0, r0), ...circle(offset, r1)]);
}

export function sphereOf(centre, r, P, { steps = 48 } = {}) {
  const V = viewOf(P);
  const L = lightOf(P);
  const F = frameAlong(centre, V);
  const flat = (p) => iso(p, P);
  const rim = Array.from({ length: steps }, (_, index) => pointOf(F, 0, r, (index / steps) * TAU));
  const outline = pathOf(rim.map(flat), true);
  const capOf = (threshold) => {
    const points = rim.filter((p) => dot3(sub3(p, centre), L) / r >= threshold).map(flat);
    const c = dot3(L, V);
    const LF = frameAlong(add3(centre, mul3(L, r * threshold)), L);
    const radius = r * Math.sqrt(Math.max(0, 1 - threshold * threshold));
    for (let index = 0; index < steps; index++) {
      const p = pointOf(LF, 0, radius, (index / steps) * TAU);
      if (dot3(sub3(p, centre), V) >= -1e-9) points.push(flat(p));
    }
    if (c > threshold) points.push(flat(add3(centre, mul3(V, r))));
    return points.length > 2 ? pathOf(hullOf(points), true) : "";
  };
  const shades = [outline, capOf(BANDS[1]), capOf(BANDS[2]), capOf(BANDS[3])];
  return { fill: outline, outline, crease: "", top: "", shades, seamless: true };
}

export const disc = (s0, s1, r, F, P, options = {}) =>
  lathe(
    [
      [s0, 0],
      [s0, r],
      [s1, r],
      [s1, 0],
    ],
    F,
    P,
    { steps: stepsFor(r, 32, 160, scaleOf(P)), ...options },
  );

export const ringBand = (s0, s1, rIn, rOut, F, P, options = {}) =>
  lathe(
    [
      [s0, rIn],
      [s0, rOut],
      [s1, rOut],
      [s1, rIn],
    ],
    F,
    P,
    { steps: stepsFor(rOut, 32, 160, scaleOf(P)), hole: null, ...options },
  );

export function solidOf(profile, F, P, options = {}) {
  const first = profile[0];
  const last = profile[profile.length - 1];
  const meridian = [[first[0], 0], ...profile, [last[0], 0]];
  const most = Math.max(...profile.map(([, r]) => r));
  return lathe(meridian, F, P, { steps: stepsFor(most, 32, 160, scaleOf(P)), ...options });
}

export function bandOfProfile(profile, F, P, options = {}) {
  const most = Math.max(...profile.map(([, r]) => r));
  return lathe(profile, F, P, { steps: stepsFor(most, 32, 160, scaleOf(P)), ...options });
}

export function silhouettePoints(profile, F, P, { samples = 72, slope: given = null, radius = null } = {}) {
  const V = viewOf(P);
  const aV = dot3(F.a, V);
  const uV = dot3(F.u, V);
  const vV = dot3(F.v, V);
  const facing = Math.atan2(vV, uV);
  const flat = Math.hypot(uV, vV) || 1e-9;
  const s0 = profile[0][0];
  const s1 = profile[profile.length - 1][0];
  const radiusAt = radius ?? ((s) => {
    let index = 1;
    while (index < profile.length - 1 && profile[index][0] < s) index++;
    const [sa, ra] = profile[index - 1];
    const [sb, rb] = profile[index];
    return ra + ((rb - ra) * (s - sa)) / (sb - sa || 1);
  });
  const side = (sign) => {
    const points = [];
    for (let step = 0; step <= samples; step++) {
      const s = s0 + ((s1 - s0) * step) / samples;
      const h = (s1 - s0) / samples / 2 || 0.01;
      const slope = given ? given(s) : (radiusAt(Math.min(s1, s + h)) - radiusAt(Math.max(s0, s - h))) / (Math.min(s1, s + h) - Math.max(s0, s - h) || 1);
      const c = (slope * aV) / flat;
      if (Math.abs(c) > 1) continue;
      points.push(pointOf(F, s, radiusAt(s), facing + sign * Math.acos(c)));
    }
    return points;
  };
  return [side(1), side(-1)];
}

export const silhouetteOf = (profile, F, P, options = {}) =>
  silhouettePoints(profile, F, P, options)
    .map((points) => pathOf(points.map((point) => iso(point, P))))
    .join("");

export function profileCurve(profile, { slope = null, radius = null } = {}) {
  for (let index = 1; index < profile.length; index++) if (profile[index][0] - profile[index - 1][0] <= 1e-9) throw new Error(`profileCurve: s must rise along the profile (point ${index} at s = ${profile[index][0]})`);
  const segments = curveRuns(profile);
  const s0 = profile[0][0];
  const s1 = profile[profile.length - 1][0];
  const find = (s) => {
    let lo = 0;
    let hi = profile.length - 2;
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1;
      if (profile[mid][0] <= s) lo = mid;
      else hi = mid - 1;
    }
    return lo;
  };
  const linear = (index, s) => {
    const [sa, ra] = profile[index];
    const [sb, rb] = profile[index + 1];
    return [ra + ((rb - ra) * (s - sa)) / (sb - sa), (rb - ra) / (sb - sa)];
  };
  const radiusAt = radius ?? ((s) => {
    const at = Math.max(s0, Math.min(s1, s));
    const index = find(at);
    return segments[index] ? segments[index].radius(at) : linear(index, at)[0];
  });
  const slopeAt = slope ?? ((s) => {
    const at = Math.max(s0, Math.min(s1, s));
    const index = find(at);
    return segments[index] ? segments[index].slope(at) : linear(index, at)[1];
  });
  return { s0, s1, radius: radiusAt, slope: slopeAt, analytic: Boolean(slope && radius) };
}

export function bandsOf(profile, cuts, F, P, { slope = null, radius = null, steps, rows = 6, rims = "none", fill = "auto", samples = 72 } = {}) {
  const curve = profileCurve(profile, { slope, radius });
  const { s0, s1 } = curve;
  const stops = [s0, ...[...new Set(cuts)].filter((s) => s > s0 + 1e-6 && s < s1 - 1e-6).sort((a, b) => a - b), s1];
  const most = Math.max(...profile.map(([, r]) => r), ...stops.map((s) => curve.radius(s)));
  const n = steps ?? stepsFor(most, 32, 160, scaleOf(P));
  const knot = (s, soft) => (soft ? [s, curve.radius(s), 1] : [s, curve.radius(s)]);
  const meridianOf = (a, b) => [knot(a), ...profile.filter(([s]) => s > a + 1e-9 && s < b - 1e-9).map(([s, , soft]) => knot(s, soft)), knot(b)];
  const shading = { slope: curve.slope, radius: curve.radius, rows };
  const bands = stops.slice(0, -1).map((a, index) => {
    const b = stops[index + 1];
    const meridian = meridianOf(a, b);
    const paths = lathe(meridian, F, P, { steps: n, rims, fill, smooth: shading });
    const seam = index < stops.length - 2 ? arcOf(F, b, curve.radius(b), P, { slope: curve.slope(b) }) : "";
    return { s0: a, s1: b, profile: meridian, paths: { ...paths, outline: "" }, outline: paths.outline, seam };
  });
  const sidesOf = (from = s0, to = s1, { ends = false, count = samples } = {}) => {
    const span = [Math.max(s0, from), Math.min(s1, to)];
    const dense = Array.from({ length: count + 1 }, (_, index) => knot(span[0] + ((span[1] - span[0]) * index) / count, true));
    const points = silhouettePoints(dense, F, P, { samples: count, slope: curve.slope, radius: curve.radius });
    const rim = (s) => arcOf(F, s, curve.radius(s), P, { slope: curve.slope(s) });
    const rimsAt = ends === true ? span : ends === "first" ? [span[0]] : ends === "last" ? [span[1]] : [];
    return { d: points.map((list) => pathOf(list.map((point) => iso(point, P)))).join("") + rimsAt.map(rim).join(""), points };
  };
  return { bands, sidesOf, radius: curve.radius, slope: curve.slope, steps: n, s0, s1 };
}

export function arcOf(F, s, r, P, { slope = 0, steps = 0, least = 0.02, inward = false, from = 0, to = TAU, trim = 0.3 } = {}) {
  const V = viewOf(P);
  const aV = dot3(F.a, V);
  const uV = dot3(F.u, V);
  const vV = dot3(F.v, V);
  const flat = Math.hypot(uV, vV);
  const phi = Math.atan2(vV, uV);
  const norm = Math.hypot(1, slope);
  const full = Math.abs(to - from - TAU) < 1e-9;
  let visible;
  if (flat < 1e-9) visible = (inward ? slope * aV / norm > least : -slope * aV / norm > least) ? [[phi, phi + TAU]] : [];
  else if (inward) {
    const c = (slope * aV - least * norm) / flat;
    visible = c <= -1 ? [] : c >= 1 ? [[phi, phi + TAU]] : [[phi + Math.acos(c), phi + TAU - Math.acos(c)]];
  } else {
    const c = (least * norm + slope * aV) / flat;
    visible = c >= 1 ? [] : c <= -1 ? [[phi, phi + TAU]] : [[phi - Math.acos(c), phi + Math.acos(c)]];
  }
  const at = (t) => iso(pointOf(F, s, r, t), P);
  const speed = (t) => {
    const a = at(t - 1e-3);
    const b = at(t + 1e-3);
    return Math.hypot(b[0] - a[0], b[1] - a[1]) / 2e-3;
  };
  const count = (span) => Math.max(2, Math.ceil(((steps || stepsFor(r, 24, 112)) * span) / TAU), Math.ceil((span * r * scaleOf(P)) / 1.5));
  const out = [];
  for (const [a, b] of visible) {
    if (b - a >= TAU - 1e-9 && full) {
      const n = count(TAU);
      out.push(pathOf(Array.from({ length: n }, (_, j) => at(from + (j / n) * TAU)), true));
      continue;
    }
    const pieces = full ? [[a, b, true, true]] : [];
    if (!full)
      for (let turn = -2; turn <= 2; turn++) {
        const lo = Math.max(a + turn * TAU, from);
        const hi = Math.min(b + turn * TAU, to);
        if (hi - lo > 1e-6) pieces.push([lo, hi, lo > from + 1e-9, hi < to - 1e-9]);
      }
    for (const [lo, hi, limb0, limb1] of pieces) {
      const span = hi - lo;
      const cut0 = limb0 && trim > 0 ? Math.min(span * 0.25, trim / (speed(lo) || 1)) : 0;
      const cut1 = limb1 && trim > 0 ? Math.min(span * 0.25, trim / (speed(hi) || 1)) : 0;
      const t0 = lo + cut0;
      const t1 = hi - cut1;
      const n = count(t1 - t0);
      out.push(pathOf(Array.from({ length: n + 1 }, (_, j) => at(t0 + ((t1 - t0) * j) / n))));
    }
  }
  return out.join("");
}

export function circleOf(F, s, r, P, steps = 0) {
  const count = steps || stepsFor(r, 24, 112);
  return pathOf(
    Array.from({ length: count }, (_, j) => iso(pointOf(F, s, r, (j / count) * TAU), P)),
    true,
  );
}

export function spokesOf(F, s, r0, r1, count, P, { phase = 0, sweep = 0, from = 0, to = TAU } = {}) {
  const lines = [];
  for (let index = 0; index < count; index++) {
    const t = phase + from + (index / count) * (to - from);
    lines.push(pathOf([iso(pointOf(F, s, r0, t), P), iso(pointOf(F, s, r1, t + sweep), P)]));
  }
  return lines.join("");
}

export const fadeOf = (value, [lo, hi]) => {
  const t = Math.max(0, Math.min(1, (value - lo) / (hi - lo || 1e-9)));
  return t * t * (3 - 2 * t);
};

export function ribsOf(F, s0, s1, r, count, P, { phase = 0, twist = 0, least = 0.12, r1 = r, fade = null, seams = false } = {}) {
  const V = viewOf(P);
  const lines = [];
  const slope = (r1 - r) / (s1 - s0 || 1);
  for (let index = 0; index < count; index++) {
    const t = phase + (index / count) * TAU;
    const radial = radialOf(F, t + twist / 2);
    const normal = unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]);
    const facing = dot3(normal, V);
    const d = pathOf([iso(pointOf(F, s0, r, t), P), iso(pointOf(F, s1, r1, t + twist), P)]);
    if (fade) {
      const alpha = fadeOf(facing, fade);
      if (alpha > 0.02) lines.push({ d, alpha });
      continue;
    }
    if (facing < least) continue;
    lines.push(d);
  }
  const ends = seams ? [seams === "last" ? "" : arcOf(F, s0, r, P, { slope }), seams === "first" ? "" : arcOf(F, s1, r1, P, { slope })].filter(Boolean) : [];
  if (fade) return [...lines, ...ends.map((d) => ({ d, alpha: 1 }))];
  return [...lines, ...ends].join("");
}

export function rodOut(F, from, to, r, P, { stroke = 0, ...options } = {}) {
  const pad = 0.35 / scaleOf(P);
  const ring = (s) => Array.from({ length: 48 }, (_, index) => iso(pointOf(F, s, r + pad, (index / 48) * TAU), P));
  return {
    paths: disc(from - stroke, to, r, F, P, options),
    clip: pathOf(hullOf([...ring(from), ...ring(to + stroke)]), true),
    from,
    to,
    stroke,
    reach: to + stroke,
  };
}

export function dotsOf(F, s, r, count, P, { phase = 0, slope = 0, least = 0.08, all = false, fade = null } = {}) {
  const V = viewOf(P);
  const out = [];
  for (let index = 0; index < count; index++) {
    const t = phase + (index / count) * TAU;
    let alpha = 1;
    if (!all) {
      const radial = radialOf(F, t);
      const normal = unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]);
      const facing = dot3(normal, V);
      if (fade) alpha = fadeOf(facing, fade);
      else if (facing < least) continue;
      if (alpha <= 0.02) continue;
    }
    const [x, y] = iso(pointOf(F, s, r, t), P);
    out.push(alpha < 1 ? [x, y, alpha] : [x, y]);
  }
  return out;
}

export function facingOf(F, angle, P, slope = 0) {
  const radial = radialOf(F, angle);
  return dot3(unit3([radial[0] - slope * F.a[0], radial[1] - slope * F.a[1], radial[2] - slope * F.a[2]]), viewOf(P));
}

export function fillet(points, radius, perArc = 7, { P = null, tube = 0, margin = 1.3 } = {}) {
  if (points.length < 3) return points.map((p) => [...p]);
  const V = P && tube ? viewOf(P) : null;
  const out = [[...points[0]]];
  for (let index = 1; index < points.length - 1; index++) {
    const prev = points[index - 1];
    const here = points[index];
    const next = points[index + 1];
    const into = unit3(sub3(here, prev));
    const outOf = unit3(sub3(next, here));
    const cosTurn = Math.max(-1, Math.min(1, dot3(into, outOf)));
    const turn = Math.acos(cosTurn);
    if (turn < 1e-3) {
      out.push([...here]);
      continue;
    }
    const room = Math.min(len3(sub3(here, prev)), len3(sub3(next, here))) * 0.48;
    const facing = V ? Math.abs(dot3(unit3(cross3(into, outOf)), V)) : 1;
    const bend = V ? Math.max(radius, (margin * tube) / (0.7 * Math.max(0.05, facing) ** 2)) : radius;
    const cut = Math.min(bend * Math.tan(turn / 2), room);
    const a = sub3(here, mul3(into, cut));
    const b = add3(here, mul3(outOf, cut));
    const count = Math.max(2, Math.ceil((perArc * turn) / (Math.PI / 2)));
    for (let step = 0; step <= count; step++) {
      const t = step / count;
      const p0 = lerp3(a, here, t);
      const p1 = lerp3(here, b, t);
      out.push(lerp3(p0, p1, t));
    }
  }
  out.push([...points[points.length - 1]]);
  return out;
}

export function resample(points, spacing) {
  const lengths = [0];
  for (let index = 1; index < points.length; index++) lengths.push(lengths[index - 1] + len3(sub3(points[index], points[index - 1])));
  const total = lengths[lengths.length - 1];
  const count = Math.max(1, Math.round(total / spacing));
  const out = [];
  let segment = 1;
  for (let step = 0; step <= count; step++) {
    const at = (total * step) / count;
    while (segment < points.length - 1 && lengths[segment] < at) segment++;
    const a = points[segment - 1];
    const b = points[segment];
    const span = lengths[segment] - lengths[segment - 1] || 1;
    out.push(lerp3(a, b, Math.min(1, Math.max(0, (at - lengths[segment - 1]) / span))));
  }
  return out;
}

export function pathLength(points) {
  let total = 0;
  for (let index = 1; index < points.length; index++) total += len3(sub3(points[index], points[index - 1]));
  return total;
}

export function cut(points, from, to) {
  const out = [];
  let walked = 0;
  for (let index = 1; index < points.length; index++) {
    const a = points[index - 1];
    const b = points[index];
    const length = len3(sub3(b, a));
    const start = walked;
    const end = walked + length;
    if (end >= from && start <= to) {
      const t0 = Math.max(0, (from - start) / (length || 1));
      const t1 = Math.min(1, (to - start) / (length || 1));
      if (!out.length) out.push(lerp3(a, b, t0));
      out.push(lerp3(a, b, t1));
    }
    walked = end;
  }
  return out;
}

export const pointAlong = (points, at) => {
  const piece = cut(points, at, at + 0.01);
  return piece[0] ?? points[points.length - 1];
};

export function tangentAlong(points, at) {
  const total = pathLength(points);
  const a = pointAlong(points, Math.max(0, at - 0.6));
  const b = pointAlong(points, Math.min(total, at + 0.6));
  return unit3(sub3(b, a));
}

export function spiral(F, s, r0, r1, a0, a1, steps = 28) {
  const out = [];
  for (let index = 0; index <= steps; index++) {
    const t = index / steps;
    out.push(pointOf(F, s, r0 + (r1 - r0) * t, a0 + (a1 - a0) * t));
  }
  return out;
}

export function arcPoints(F, s, r, a0, a1, steps = 24) {
  return Array.from({ length: steps + 1 }, (_, index) => pointOf(F, s, r, a0 + ((a1 - a0) * index) / steps));
}
```

## kit/react

```

```

## kit/react/draw.tsx

```
import * as React from "react";
import { pathOf, type Flat, type KeyMark, type LegendKey, type LineTone, type SolidPaths, type Tone } from "../iso-kit";
import { Stage } from "./live";

interface SolidProps {
  paths: SolidPaths;
  tone?: Tone;
  crease?: Tone | "none";
  halo?: boolean;
  lit?: boolean;
  flat?: boolean;
  className?: string;
  inner?: React.ReactNode;
  children?: React.ReactNode;
}

export function Solid({ paths, tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner, children }: SolidProps) {
  return (
    <g className={className ? `iso-solid ${className}` : "iso-solid"} data-lit={lit ? "" : undefined}>
      {halo ? <path className="iso-halo" d={paths.fill} /> : null}
      <path className="iso-fill" d={paths.fill} />
      {flat ? null : paths.shades.map((d, shade) => (d ? <path key={shade} className="iso-shade" data-shade={shade} d={d} /> : null))}
      {flat ? null : <path className="iso-top" d={paths.top} />}
      {crease === "none" || !paths.crease ? null : <path className="iso-line iso-crease" data-tone={crease} d={paths.crease} />}
      {crease === "none" || !paths.bevel ? null : <path className="iso-line iso-bevel" data-tone={crease} d={paths.bevel} />}
      {inner}
      <path className="iso-line iso-edge" data-tone={tone} d={paths.outline} />
      {children}
    </g>
  );
}

interface LineProps {
  d: string;
  tone?: LineTone;
  dotted?: boolean;
  dashed?: boolean;
  flow?: boolean;
  className?: string;
  free?: boolean | "start" | "end";
}

export function Line({ d, tone = "lo", dotted = false, dashed = false, flow = false, className, free }: LineProps) {
  if (!d) return null;
  return (
    <path
      className={className ? `iso-line ${className}` : "iso-line"}
      data-tone={tone}
      data-dotted={dotted ? "" : undefined}
      data-dashed={dashed ? "" : undefined}
      data-flow={flow ? "" : undefined}
      data-free={free === true ? "" : free || undefined}
      d={d}
    />
  );
}

interface WireProps {
  from: Flat;
  to: Flat;
  via?: Flat[];
  tone?: LineTone;
  dotted?: boolean;
  dashed?: boolean;
  flow?: boolean;
  ends?: "both" | "from" | "to" | "none";
  size?: number;
}

const tenth = (value: number) => Math.round(value * 10) / 10;

export function Wire({ from, to, via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 }: WireProps) {
  return (
    <g className="iso-wire" data-tone={tone}>
      <Line d={pathOf([from, ...via, to])} tone={tone} dotted={dotted} dashed={dashed} flow={flow} />
      {ends === "both" || ends === "from" ? <circle className="iso-wire-end" cx={tenth(from[0])} cy={tenth(from[1])} r={size} /> : null}
      {ends === "both" || ends === "to" ? <circle className="iso-wire-end" cx={tenth(to[0])} cy={tenth(to[1])} r={size} /> : null}
    </g>
  );
}

export function Dots({ points, size = 0.5, tone = "mid", pulse = false }: { points: Flat[]; size?: number; tone?: Tone; pulse?: boolean }) {
  return (
    <g className="iso-dots" data-tone={tone}>
      {points.map(([x, y], index) => (
        <circle
          key={index}
          cx={tenth(x)}
          cy={tenth(y)}
          r={size}
          data-pulse={pulse ? "" : undefined}
          style={pulse ? ({ "--pulse-at": `${(index * 137) % 1600}ms` } as React.CSSProperties) : undefined}
        />
      ))}
    </g>
  );
}

export function FaceText({
  transform,
  children,
  size = 3,
  tone = "mid",
  anchor = "start",
}: {
  transform: string;
  children: React.ReactNode;
  size?: number;
  tone?: Tone;
  anchor?: "start" | "middle" | "end";
}) {
  return (
    <text className="iso-face-text" data-tone={tone} transform={transform} fontSize={size} textAnchor={anchor}>
      {children}
    </text>
  );
}

interface IsoFigureProps {
  width: number;
  height: number;
  label: string;
  className?: string;
  children: React.ReactNode;
}

export function IsoFigure({ width, height, label, className, children }: IsoFigureProps) {
  return (
    <Stage className={className ? `iso-stage ${className}` : "iso-stage"}>
      <svg className="iso-svg" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={label} focusable="false">
        {children}
      </svg>
    </Stage>
  );
}

function Swatch({ mark }: { mark: KeyMark }) {
  if (mark === "raised" || mark === "flat" || mark === "lit") {
    return (
      <svg className="iso-swatch" data-mark={mark} viewBox="0 0 16 10" aria-hidden="true" focusable="false">
        <path d={mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z"} />
        {mark === "flat" ? null : <path className="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5" />}
      </svg>
    );
  }
  return (
    <svg className="iso-swatch" data-mark={mark} viewBox="0 0 16 10" aria-hidden="true" focusable="false">
      <path d="M1.5 5h13" />
    </svg>
  );
}

interface PlateProps {
  fig: string;
  title?: string;
  hint?: React.ReactNode;
  readout?: React.ReactNode;
  keys?: LegendKey[];
  caption?: React.ReactNode;
  children: React.ReactNode;
}

export function Plate({ fig, title, hint, readout, keys = [], caption, children }: PlateProps) {
  return (
    <figure className="iso-figure">
      <div className="iso-plate">
        <div className="iso-plate-corners" aria-hidden="true">
          <span className="iso-plate-corner" data-corner="fig">
            {fig}
          </span>
          {title ? (
            <span className="iso-plate-corner" data-corner="title">
              {title}
            </span>
          ) : null}
          {hint ? (
            <span className="iso-plate-corner" data-corner="hint">
              {hint}
            </span>
          ) : null}
          <span className="iso-plate-corner" data-corner="readout" data-readout="">
            {readout}
          </span>
        </div>
        {children}
      </div>
      {keys.length || caption ? (
        <figcaption className="iso-legend">
          {keys.length ? (
            <span className="iso-keys">
              {keys.map((key) => (
                <span className="iso-key" key={key.label}>
                  <Swatch mark={key.mark} />
                  {key.label}
                </span>
              ))}
            </span>
          ) : null}
          {caption ? <span className="iso-caption">{caption}</span> : null}
        </figcaption>
      ) : null}
    </figure>
  );
}
```

## kit/react/live.tsx

```
"use client";

import * as React from "react";

const STILLNESS = "(prefers-reduced-motion: reduce)";
const MARGIN = "120px 0px";
const LONGEST_FRAME = 1 / 30;

export const asksForStillness = () => typeof window !== "undefined" && window.matchMedia(STILLNESS).matches;

export function useStillness() {
  return React.useSyncExternalStore(
    (changed) => {
      const query = window.matchMedia(STILLNESS);
      query.addEventListener("change", changed);
      return () => query.removeEventListener("change", changed);
    },
    asksForStillness,
    () => false,
  );
}

export function useInView<T extends Element>(ref: React.RefObject<T | null>) {
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    const node = ref.current;
    if (!node) return;
    const observer = new IntersectionObserver((entries) => setInView(Boolean(entries.at(-1)?.isIntersecting)), { rootMargin: MARGIN });
    observer.observe(node);
    return () => observer.disconnect();
  }, [ref]);
  return inView;
}

export function Stage({ className, children }: { className: string; children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref);
  return (
    <div ref={ref} className={className} data-inview={inView ? "" : undefined}>
      {children}
    </div>
  );
}

export function useReadout<T extends Element>() {
  const anchor = React.useRef<T>(null);
  const slot = React.useRef<Element | null>(null);
  const said = React.useRef("");
  React.useEffect(() => {
    slot.current = anchor.current?.closest(".iso-plate")?.querySelector("[data-readout]") ?? null;
  }, []);
  const say = React.useCallback((text: string) => {
    if (text === said.current) return;
    said.current = text;
    if (slot.current) slot.current.textContent = text;
  }, []);
  return { anchor, say };
}

export function useLoop(tick: (dt: number, calm: boolean) => boolean, running: boolean) {
  const latest = React.useRef(tick);
  latest.current = tick;
  const frame = React.useRef(0);
  const last = React.useRef(0);
  const kick = React.useCallback(() => {
    if (frame.current) return;
    last.current = 0;
    const step = (now: number) => {
      const dt = last.current ? Math.min((now - last.current) / 1000, LONGEST_FRAME) : 1 / 60;
      last.current = now;
      const more = latest.current(dt, asksForStillness());
      frame.current = more ? requestAnimationFrame(step) : 0;
    };
    frame.current = requestAnimationFrame(step);
  }, []);
  React.useEffect(() => {
    if (running) kick();
    else {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    }
    return () => {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    };
  }, [running, kick]);
  return kick;
}

export function follow(current: number, target: number, dt: number, seconds: number, calm = false) {
  if (calm) return target;
  const next = current + (target - current) * (1 - Math.exp(-dt / seconds));
  return Math.abs(target - next) < 1e-3 ? target : next;
}

export interface Spring {
  at: number;
  rate: number;
}

export function spring(state: Spring, target: number, dt: number, stiffness: number, damping: number, calm = false) {
  const drag = calm ? 2 * Math.sqrt(stiffness) : damping;
  const steps = Math.max(Math.ceil(dt * 240), 1);
  const slice = dt / steps;
  for (let step = 0; step < steps; step++) {
    state.rate += (target - state.at) * stiffness * slice - state.rate * drag * slice;
    state.at += state.rate * slice;
  }
  if (Math.abs(target - state.at) < 5e-4 && Math.abs(state.rate) < 5e-4) {
    state.at = target;
    state.rate = 0;
  }
  return state.at === target && state.rate === 0;
}

export function useEased(target: number, seconds = 0.12) {
  const [value, setValue] = React.useState(target);
  const shown = React.useRef(target);
  useLoop((dt, calm) => {
    shown.current = follow(shown.current, target, dt, seconds, calm);
    setValue(shown.current);
    return shown.current !== target;
  }, shown.current !== target);
  return value;
}
```

## kit/tube.mjs

```js
import { iso, pathOf } from "./iso-kit.mjs";
import * as G from "./lathe.mjs";

const LIGHT = (() => {
  const length = Math.hypot(-0.55, -0.835);
  return [-0.55 / length, -0.835 / length];
})();

const poly = (points, closed = true) => pathOf(points, closed);

const area = (points) => points.reduce((sum, [x, y], index) => {
  const [nx, ny] = points[(index + 1) % points.length];
  return sum + x * ny - nx * y;
}, 0);

function sideOf(frames) {
  const out = { left: [], right: [], shineA: [], shineB: [], shadeA: [], shadeB: [] };
  for (const { p, nx, ny, half } of frames) {
    out.left.push([p[0] + nx * half, p[1] + ny * half]);
    out.right.push([p[0] - nx * half, p[1] - ny * half]);
    const toward = nx * LIGHT[0] + ny * LIGHT[1];
    const shineAt = half * 0.44 * toward;
    const shineHalf = half * 0.24;
    out.shineA.push([p[0] + nx * (shineAt + shineHalf), p[1] + ny * (shineAt + shineHalf)]);
    out.shineB.push([p[0] + nx * (shineAt - shineHalf), p[1] + ny * (shineAt - shineHalf)]);
    const shadeAt = -half * 0.62 * toward;
    const shadeHalf = half * 0.2;
    out.shadeA.push([p[0] + nx * (shadeAt + shadeHalf), p[1] + ny * (shadeAt + shadeHalf)]);
    out.shadeB.push([p[0] + nx * (shadeAt - shadeHalf), p[1] + ny * (shadeAt - shadeHalf)]);
  }
  return out;
}

function capOf(centre, tangent, radius, inward, P) {
  const V = G.viewOf(P);
  let n1 = G.cross3(tangent, V);
  if (G.len3(n1) < 1e-6) n1 = G.frameAlong([0, 0, 0], tangent).u;
  n1 = G.unit3(n1);
  const n2 = G.unit3(G.cross3(tangent, n1));
  const steps = 28;
  const ring = Array.from({ length: steps }, (_, index) => {
    const angle = (index / steps) * Math.PI * 2;
    return iso(G.add3(centre, G.add3(G.mul3(n1, radius * Math.cos(angle)), G.mul3(n2, radius * Math.sin(angle)))), P);
  });
  const c = iso(centre, P);
  const probe = iso(G.add3(centre, n2), P);
  const along = (probe[0] - c[0]) * inward[0] + (probe[1] - c[1]) * inward[1];
  const from = along > 0 ? steps / 2 : 0;
  const half = (start) => Array.from({ length: steps / 2 + 1 }, (_, index) => ring[(start + index) % steps]);
  return { ring, arc: half(from), inner: half((from + steps / 2) % steps) };
}

function stripeEnd(half, centre, nx, ny, from, to) {
  const offset = (p) => (p[0] - centre[0]) * nx + (p[1] - centre[1]) * ny;
  const values = half.map(offset);
  const lo = Math.min(from, to);
  const hi = Math.max(from, to);
  const points = [];
  const cross = (k, level) => {
    const t = (level - values[k]) / (values[k + 1] - values[k] || 1e-9);
    return [half[k][0] + (half[k + 1][0] - half[k][0]) * t, half[k][1] + (half[k + 1][1] - half[k][1]) * t];
  };
  for (let k = 0; k < half.length; k++) {
    if (values[k] >= lo && values[k] <= hi) points.push(half[k]);
    if (k < half.length - 1) for (const level of [lo, hi]) if ((values[k] - level) * (values[k + 1] - level) < 0) points.push(cross(k, level));
  }
  const order = points.map((p) => offset(p));
  const sorted = points.map((p, index) => [p, order[index]]).sort((x, y) => (from > to ? y[1] - x[1] : x[1] - y[1]));
  return sorted.map(([p]) => p);
}

function spansOf(total, gaps, breaks, maxLength, closed) {
  const cuts = gaps
    .map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
    .filter(([a, b]) => b - a > 1e-6)
    .sort((x, y) => x[0] - y[0]);
  const runs = [];
  let at = 0;
  for (const [a, b] of cuts) {
    if (a > at + 1e-6) runs.push([at, a, at > 0, true]);
    at = Math.max(at, b);
  }
  if (total > at + 1e-6) runs.push([at, total, at > 0, cuts.length > 0 && cuts[cuts.length - 1][1] >= total - 1e-6]);
  const pieces = [];
  for (const [from, to, gapStart, gapEnd] of runs) {
    const stops = [from, ...breaks.filter((b) => b > from + 1e-6 && b < to - 1e-6).sort((x, y) => x - y), to];
    for (let index = 0; index < stops.length - 1; index++) {
      const a = stops[index];
      const b = stops[index + 1];
      const count = Math.max(1, Math.ceil((b - a) / maxLength - 1e-9));
      for (let c = 0; c < count; c++) {
        pieces.push({
          from: a + ((b - a) * c) / count,
          to: a + ((b - a) * (c + 1)) / count,
          open0: !(index === 0 && c === 0),
          open1: !(index === stops.length - 2 && c === count - 1),
          gap0: index === 0 && c === 0 && gapStart,
          gap1: index === stops.length - 2 && c === count - 1 && gapEnd,
        });
      }
    }
  }
  if (closed && !cuts.length && pieces.length) {
    pieces[0].open0 = true;
    pieces[pieces.length - 1].open1 = true;
  }
  return pieces;
}

export function runsOf(total, gaps = []) {
  const cuts = gaps
    .map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
    .filter(([a, b]) => b - a > 1e-6)
    .sort((x, y) => x[0] - y[0]);
  const runs = [];
  let at = 0;
  for (const [a, b] of cuts) {
    if (a > at + 1e-6) runs.push([at, a]);
    at = Math.max(at, b);
  }
  if (total > at + 1e-6) runs.push([at, total]);
  return runs;
}

export function crossingsOf(points, centre, axis, radius) {
  const n = G.unit3(axis);
  const out = [];
  let walked = 0;
  for (let index = 1; index < points.length; index++) {
    const a = points[index - 1];
    const b = points[index];
    const length = G.len3(G.sub3(b, a));
    const da = G.dot3(G.sub3(a, centre), n);
    const db = G.dot3(G.sub3(b, centre), n);
    if ((da <= 0 && db > 0) || (da >= 0 && db < 0)) {
      const t = da / (da - db);
      const p = G.lerp3(a, b, t);
      if (G.len3(G.sub3(p, centre)) <= radius) out.push(walked + t * length);
    }
    walked += length;
  }
  return out;
}

function ringsOf(dense, total, radiusAt, P, span, { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16 } = {}) {
  const V = G.viewOf(P);
  const [lo, hi] = span;
  const margin = twist ? 0 : Math.min(pitch * 0.35, (hi - lo) / 2);
  const from = Math.max(lo, span.real0 ? lo + margin : lo);
  const to = Math.min(hi, span.real1 ? hi - margin : hi);
  if (to <= from) return [];
  const out = [];
  const hands = twist ? (cross ? [1, -1] : [1]) : [0];
  const reach = Math.abs(twist) * Math.max(...[0, 0.5, 1].map(radiusAt));
  const first = Math.ceil((from - reach - phase) / pitch);
  const last = Math.floor((to + reach - phase) / pitch);
  const surface = (t, theta) => {
    const at = Math.max(0, Math.min(total, t));
    const c = G.pointAlong(dense, at);
    const T = G.tangentAlong(dense, at);
    let n1 = G.cross3(T, V);
    if (G.len3(n1) < 1e-6) return null;
    n1 = G.unit3(n1);
    let n2 = G.unit3(G.cross3(T, n1));
    if (G.dot3(n2, V) < 0) n2 = G.mul3(n2, -1);
    const r = radiusAt(at / (total || 1));
    const normal = G.add3(G.mul3(n1, Math.cos(theta)), G.mul3(n2, Math.sin(theta)));
    return { p: iso(G.add3(c, G.mul3(normal, r)), P), facing: G.dot3(normal, V), r };
  };
  for (let index = first; index <= last; index++) {
    const station = phase + index * pitch;
    for (const hand of hands) {
      const samples = [];
      for (let step = 0; step <= steps; step++) {
        const theta = (step / steps) * Math.PI;
        const t = station + hand * twist * radiusAt(Math.max(0, Math.min(1, station / (total || 1)))) * (step / steps - 0.5);
        samples.push({ t, theta });
      }
      for (let step = 0; step < steps; step++) {
        let a = samples[step];
        let b = samples[step + 1];
        if ((a.t < from && b.t < from) || (a.t > to && b.t > to)) continue;
        const clipTo = (x, y, edge) => ({ t: edge, theta: x.theta + ((y.theta - x.theta) * (edge - x.t)) / (y.t - x.t || 1e-9) });
        if (a.t < from) a = clipTo(a, b, from);
        if (b.t < from) b = clipTo(b, a, from);
        if (a.t > to) a = clipTo(a, b, to);
        if (b.t > to) b = clipTo(b, a, to);
        const pa = surface(a.t, a.theta);
        const pb = surface(b.t, b.theta);
        if (!pa || !pb) continue;
        const alpha = fade ? G.fadeOf((pa.facing + pb.facing) / 2, fade) : 1;
        if (alpha <= 0.02) continue;
        out.push({ d: pathOf([pa.p, pb.p]), alpha });
      }
    }
  }
  return out;
}

export function tubePieces(points3, radius, P, { maxLength = 30, spacing = 2.2, closed = false, breaks = [], gaps = [], caps = true, stripes = "auto", rings = null } = {}) {
  const dense = G.resample(points3, spacing);
  const total = G.pathLength(dense);
  const radiusAt = typeof radius === "function" ? radius : () => radius;
  const k = G.scaleOf(P);
  const wide = stripes === "auto" ? Math.max(...Array.from({ length: 33 }, (_, index) => radiusAt(index / 32))) * k > 1.3 : Boolean(stripes);
  const lead = 0.5;
  const out = [];
  const looped = closed && !gaps.length;
  const wrap = (at) => (looped ? ((at % total) + total) % total : Math.max(0, Math.min(total, at)));
  const step = Math.max(0.05, Math.min(spacing, total / 4) * 0.5);
  const frameAt = (at, p, width) => {
    const a = iso(G.pointAlong(dense, wrap(at - step)), P);
    const b = iso(G.pointAlong(dense, wrap(at + step)), P);
    let tx = b[0] - a[0];
    let ty = b[1] - a[1];
    const length = Math.hypot(tx, ty) || 1;
    tx /= length;
    ty /= length;
    return { p, nx: -ty, ny: tx, half: width };
  };
  const partOf = (start, end) => {
    let points = G.cut(dense, Math.max(0, start), Math.min(total, end));
    if (looped && start < 0) points = [...G.cut(dense, total + start, total), ...points.slice(1)];
    if (looped && end > total) points = [...points, ...G.cut(dense, 0, end - total).slice(1)];
    let walked = looped ? start : Math.max(0, start);
    const along = points.map((point, index) => {
      if (index > 0) walked += G.len3(G.sub3(point, points[index - 1]));
      return walked;
    });
    const flat = points.map((point) => iso(point, P));
    const widths = along.map((at) => radiusAt(looped ? wrap(at) / total : Math.min(1, Math.max(0, at / total))) * k);
    const frames = flat.map((p, index) => frameAt(along[index], p, widths[index]));
    return { points, flat, widths, frames };
  };
  for (const piece of spansOf(total, gaps, breaks, maxLength, closed)) {
    const openStart = piece.open0 && (piece.from > 0 || looped);
    const openEnd = piece.open1 && (piece.to < total || looped);
    const body = partOf(openStart ? piece.from - lead : piece.from, openEnd ? piece.to + lead : piece.to);
    if (body.points.length < 2) continue;
    const stripe = openStart || openEnd ? partOf(openStart ? piece.from - 2 * lead : piece.from, openEnd ? piece.to + 2 * lead : piece.to) : body;
    const { points: part, flat, widths, frames } = body;
    const sides = sideOf(frames);
    const bands = stripe === body ? sides : sideOf(stripe.frames);
    let bodyPath = poly([...sides.left, ...sides.right.slice().reverse()]);
    let edges = pathOf(sides.left) + pathOf(sides.right);
    const ends = [!piece.open0 && !looped && (piece.from <= 1e-6 || piece.gap0), !piece.open1 && !looped && (piece.to >= total - 1e-6 || piece.gap1)];
    const capped = [ends[0] && (piece.gap0 || [caps].flat()[0]), ends[1] && (piece.gap1 || [caps].flat().at(-1))];
    const sign = Math.sign(area([...sides.left, ...sides.right.slice().reverse()]));
    const tips = [null, null];
    [0, 1].forEach((side) => {
      if (!capped[side]) return;
      const at = side ? part.length - 1 : 0;
      const other = side ? part.length - 2 : 1;
      const tangent = G.unit3(G.sub3(part[side ? at : other], part[side ? other : at]));
      const outward = side ? tangent : G.mul3(tangent, -1);
      const inward = [flat[other][0] - flat[at][0], flat[other][1] - flat[at][1]];
      const { ring, arc, inner } = capOf(part[at], tangent, widths[at] / k, inward, P);
      const oriented = Math.sign(area(ring)) === sign ? ring : ring.slice().reverse();
      bodyPath += poly(oriented);
      edges += pathOf(arc);
      const open = G.dot3(outward, G.viewOf(P)) > 0;
      if (open) edges += pathOf(inner);
      tips[side] = { half: open ? inner : arc, frame: frames[at] };
    });
    const band = (a, b, offsets) => {
      const head = tips[1] ? stripeEnd(tips[1].half, tips[1].frame.p, tips[1].frame.nx, tips[1].frame.ny, offsets(tips[1].frame)[0], offsets(tips[1].frame)[1]) : [];
      const tail = tips[0] ? stripeEnd(tips[0].half, tips[0].frame.p, tips[0].frame.nx, tips[0].frame.ny, offsets(tips[0].frame)[1], offsets(tips[0].frame)[0]) : [];
      return poly([...a, ...head, ...b.slice().reverse(), ...tail]);
    };
    const toward = ({ nx, ny }) => nx * LIGHT[0] + ny * LIGHT[1];
    const shineOffsets = (frame) => [frame.half * 0.44 * toward(frame) + frame.half * 0.24, frame.half * 0.44 * toward(frame) - frame.half * 0.24];
    const shadeOffsets = (frame) => [-frame.half * 0.62 * toward(frame) + frame.half * 0.2, -frame.half * 0.62 * toward(frame) - frame.half * 0.2];
    const ringSpan = Object.assign([openStart ? piece.from - 2 * lead : piece.from, openEnd ? piece.to + 2 * lead : piece.to], { real0: !openStart, real1: !openEnd });
    out.push({
      rings: rings ? ringsOf(dense, total, radiusAt, P, ringSpan, rings) : [],
      ringTone: rings?.tone ?? "lo",
      mid: G.pointAlong(dense, (piece.from + piece.to) / 2),
      body: bodyPath,
      shine: band(bands.shineA, bands.shineB, shineOffsets),
      shade: band(bands.shadeA, bands.shadeB, shadeOffsets),
      edges,
      wide,
      points: part,
      radii: widths.map((width) => width / k),
      ends,
      from: piece.from,
      to: piece.to,
    });
  }
  return out;
}

export function ringsSvg(rings, tone = "lo") {
  const groups = new Map();
  for (const { d, alpha } of rings ?? []) {
    const level = Math.min(1, Math.round(alpha * 20) / 20);
    if (level <= 0) continue;
    groups.set(level, (groups.get(level) ?? "") + d);
  }
  return [...groups].map(([alpha, d]) => `<path class="tb-ring" data-tone="${tone}" d="${d}"${alpha < 1 ? ` opacity="${alpha.toFixed(2)}"` : ""}/>`).join("");
}

export function tubeSvg(piece, { tone = "hi" } = {}) {
  const shine = piece.wide ? `<path class="tb-shine" d="${piece.shine}"/><path class="tb-shade" d="${piece.shade}"/>` : "";
  return `<g class="tb"><path class="tb-body" d="${piece.body}"/>${shine}${ringsSvg(piece.rings, piece.ringTone)}<path class="iso-line tb-edge" data-tone="${tone}" d="${piece.edges}"/></g>`;
}
```

## kit/turn-audit.mjs

```js
import { TURN } from "./turn.mjs";
import * as A from "./audit.mjs";
import * as G from "./lathe.mjs";
import * as k from "./iso-kit.mjs";
import { separate, sampleShape } from "./turn-build.mjs";

const TAU = Math.PI * 2;
const EYE = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const f2 = (v) => (Math.round(v * 100) / 100).toFixed(2);
const f3 = (v) => (Math.round(v * 1000) / 1000).toFixed(3);

const apply = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];
const back = (R, v) => [R[0] * v[0] + R[3] * v[1] + R[6] * v[2], R[1] * v[0] + R[4] * v[1] + R[7] * v[2], R[2] * v[0] + R[5] * v[1] + R[8] * v[2]];

const place = (pose, p) => {
  const q = apply(pose.R, [p[0] - pose.origin[0], p[1] - pose.origin[1], p[2] - pose.origin[2]]);
  return [q[0] + pose.t[0], q[1] + pose.t[1], q[2] + pose.t[2]];
};

const unplace = (pose, p) => {
  const q = back(pose.R, [p[0] - pose.t[0], p[1] - pose.t[1], p[2] - pose.t[2]]);
  return [q[0] + pose.origin[0], q[1] + pose.origin[1], q[2] + pose.origin[2]];
};

export function posed(shape, pose) {
  if (!pose) return shape;
  const out = { ...shape, _b: undefined, _f: undefined };
  if (shape.F) out.F = { o: place(pose, shape.F.o), a: apply(pose.R, shape.F.a), u: shape.F.u ? apply(pose.R, shape.F.u) : undefined, v: shape.F.v ? apply(pose.R, shape.F.v) : undefined };
  if (shape.kind === "ball" || shape.kind === "box") out.o = place(pose, shape.o);
  if (shape.flats) out.flats = shape.flats.map(([nx, ny, nz, d]) => [...apply(pose.R, [nx, ny, nz]), d]);
  if (shape.axes) out.axes = shape.axes.map((axis) => apply(pose.R, axis));
  if (shape.points) out.points = shape.points.map((p) => place(pose, p));
  if (shape.tips) out.tips = shape.tips.map((tip) => tip && place(pose, tip));
  if (shape.tipAxes) out.tipAxes = shape.tipAxes.map((axis) => axis && apply(pose.R, axis));
  if (shape.cut) out.cut = Array.isArray(shape.cut) ? shape.cut.map((one) => posed(one, pose)) : posed(shape.cut, pose);
  return out;
}

const IDENTITY = { R: EYE, t: [0, 0, 0], origin: [0, 0, 0] };
const poseFor = (cams, group) => (group && group !== "static" ? { R: cams[group.index].R, t: cams[group.index].t, origin: group.origin } : IDENTITY);

export function eventsOf(T, layer) {
  const data = T.data;
  const name = typeof layer === "string" ? layer : layer.name;
  const prepared = TURN.prepare(data, { unit: 1 }).layers.find((l) => l.name === name);
  const plan = prepared ? prepared.planar : [];
  const a = ((T.P.azimuth ?? 45) * Math.PI) / 180;
  const e = ((T.P.elevation ?? 30) * Math.PI) / 180;
  const out = new Set();
  for (let i = 0; i < plan.length; i += 5) {
    const part = T.parts[plan[i]];
    const g = part.group;
    if (!g || g.kind !== "turn" || g.parent) continue;
    const n = [plan[i + 2], plan[i + 3], plan[i + 4]];
    const flat = Math.hypot(n[0], n[1]);
    if (flat < 1e-9) continue;
    const c = (-n[2] * Math.sin(e)) / (flat * Math.cos(e));
    if (c <= -1 || c >= 1) continue;
    const phi = Math.atan2(n[1], n[0]);
    for (const b of [phi + Math.acos(c), phi - Math.acos(c)]) {
      const theta = ((((a - b) * 180) / Math.PI) % 360 + 360) % 360;
      out.add(Math.round(theta * 1e6) / 1e6);
    }
  }
  return [...out].sort((x, y) => x - y);
}

function polygonArea(poly) {
  let s = 0;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    s += a[0] * b[1] - b[0] * a[1];
  }
  return s / 2;
}

function clipArea(subject, clip) {
  if (subject.length < 3 || clip.length < 3) return 0;
  const sign = polygonArea(clip) >= 0 ? 1 : -1;
  let out = subject;
  for (let i = 0; i < clip.length && out.length; i++) {
    const a = clip[i];
    const b = clip[(i + 1) % clip.length];
    const side = (p) => sign * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
    const input = out;
    out = [];
    for (let j = 0; j < input.length; j++) {
      const p = input[j];
      const q = input[(j + 1) % input.length];
      const sp = side(p);
      const sq = side(q);
      if (sp >= 0) out.push(p);
      if ((sp >= 0) !== (sq >= 0)) {
        const t = sp / (sp - sq);
        out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
      }
    }
  }
  return out.length >= 3 ? Math.abs(polygonArea(out)) : 0;
}

const pathPolys = (d) =>
  [...(d ?? "").matchAll(/M([^M]*)/g)].map(([, body]) => {
    const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
    const pts = [];
    for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
    return pts;
  });

function convexHull(points) {
  const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  if (sorted.length < 3) return sorted;
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lower = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
    lower.push(p);
  }
  const upper = [];
  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
    upper.push(p);
  }
  return lower.slice(0, -1).concat(upper.slice(0, -1));
}

function footprints(run, f) {
  if (run.kind === "tube") return f.quadList.map(([buf, n]) => convexHull(Array.from({ length: n }, (_, i) => [buf[2 * i], buf[2 * i + 1]])));
  if (run.kind === "fixed") return [Array.from({ length: f.hullN }, (_, i) => [f.hull[2 * i], f.hull[2 * i + 1]])];
  const polys = pathPolys(f.d[run.role.fill]);
  return polys.length ? [convexHull(polys.flat())] : [];
}

function overlapArea(runA, fa, runB, fb) {
  if (fa.box[2] < fb.box[0] || fb.box[2] < fa.box[0] || fa.box[3] < fb.box[1] || fb.box[3] < fa.box[1]) return 0;
  let best = 0;
  for (const a of footprints(runA, fa)) for (const b of footprints(runB, fb)) best = Math.max(best, clipArea(a, b));
  return best;
}

function hausdorffish(A, B) {
  if (!A.length || !B.length) return 0;
  const seg = (p, a, b) => {
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const l = dx * dx + dy * dy;
    const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
    return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
  };
  const one = (P, Q) => {
    let worst = 0;
    const m = Q.length;
    const n = P.length;
    for (let i = 0; i < n; i++) {
      const centre = Math.round((i / n) * m);
      let best = Infinity;
      for (let w = -10; w <= 10; w++) {
        const j = (((centre + w) % m) + m) % m;
        best = Math.min(best, seg(P[i], Q[j], Q[(j + 1) % m]));
      }
      if (best > 0.02) for (let j = 0; j < m; j++) best = Math.min(best, seg(P[i], Q[j], Q[(j + 1) % m]));
      worst = Math.max(worst, best);
    }
    return worst;
  };
  return Math.max(one(A, B), one(B, A));
}

const areaOfPath = (d) => pathPolys(d).reduce((sum, poly) => sum + Math.abs(polygonArea(poly)), 0);

function flatPolys(d) {
  const out = [];
  for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
    const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
    if (nums.length >= 6) out.push(nums);
  }
  return out;
}

const jitter = (a, b) => {
  const t = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
  return t - Math.floor(t);
};

function toneRaster(layers, box, scale) {
  const x0 = box[0];
  const y0 = box[1];
  const w = Math.max(1, Math.ceil((box[2] - x0) * scale));
  const h = Math.max(1, Math.ceil((box[3] - y0) * scale));
  const out = new Float32Array(w * h).fill(-1);
  const owner = new Int16Array(w * h).fill(-1);
  for (const { polys, v, id } of layers) {
    if (!polys.length) continue;
    let ymin = Infinity;
    let ymax = -Infinity;
    for (const p of polys) for (let i = 1; i < p.length; i += 2) {
      if (p[i] < ymin) ymin = p[i];
      if (p[i] > ymax) ymax = p[i];
    }
    const r0 = Math.max(0, Math.floor((ymin - y0) * scale));
    const r1 = Math.min(h - 1, Math.ceil((ymax - y0) * scale));
    const hits = [];
    for (let row = r0; row <= r1; row++)
      for (let sub = 0; sub < 8; sub++) {
      const y = y0 + (row + (sub + 0.5) / 8) / scale;
      hits.length = 0;
      for (const p of polys) {
        const n = p.length;
        for (let i = 0; i < n; i += 2) {
          const ax = p[i];
          const ay = p[i + 1];
          const bx = p[(i + 2) % n];
          const by = p[(i + 3) % n];
          if ((ay <= y) === (by <= y)) continue;
          hits.push([ax + ((y - ay) * (bx - ax)) / (by - ay), by > ay ? 1 : -1]);
        }
      }
      if (hits.length < 2) continue;
      hits.sort((a, b) => a[0] - b[0]);
      let winding = 0;
      for (let k = 0; k < hits.length - 1; k++) {
        winding += hits[k][1];
        if (!winding) continue;
        const xa = hits[k][0];
        const xb = hits[k + 1][0];
        const c0 = Math.max(0, Math.floor((xa - x0) * scale));
        const c1 = Math.min(w - 1, Math.floor((xb - x0) * scale));
        for (let c = c0; c <= c1; c++) {
          if (Math.floor(jitter(c, row) * 8) !== sub) continue;
          if (c === c0 || c === c1) {
            const x = x0 + (c + jitter(row, c)) / scale;
            if (x < xa || x >= xb) continue;
          }
          out[row * w + c] = v;
          owner[row * w + c] = id;
        }
      }
    }
  }
  return { out, owner };
}

function toneLayers(run, f) {
  const layers = [];
  run.roles.forEach((role, r) => {
    const d = f.d[r];
    if (!d) return;
    if (role.type === "face") layers.push({ polys: flatPolys(d), v: f.q[r] / TURN.q, id: r });
    else if (role.type === "path" && role.name === "fill") layers.push({ polys: flatPolys(d), v: 2, id: r });
    else if (role.type === "path" && /^s[0-4]$/.test(role.name)) layers.push({ polys: flatPolys(d), v: Number(role.name.slice(1)), id: r });
    else if (role.name === "body") layers.push({ polys: flatPolys(d), v: 2, id: r });
    else if (role.name === "shine") layers.push({ polys: flatPolys(d), v: 4, id: r });
    else if (role.name === "shade") layers.push({ polys: flatPolys(d), v: 0, id: r });
  });
  return layers;
}

function visibleChange(run, frames, scale = 4) {
  const box = [Infinity, Infinity, -Infinity, -Infinity];
  for (const f of frames) {
    box[0] = Math.min(box[0], f.box[0] - 1);
    box[1] = Math.min(box[1], f.box[1] - 1);
    box[2] = Math.max(box[2], f.box[2] + 1);
    box[3] = Math.max(box[3], f.box[3] + 1);
  }
  const rasters = frames.map((f) => toneRaster(toneLayers(run, f), box, scale));
  const steps = [];
  for (let i = 1; i < rasters.length; i++) {
    const a = rasters[i - 1];
    const b = rasters[i];
    let sum = 0;
    for (let j = 0; j < a.out.length; j++) {
      if (a.owner[j] === b.owner[j]) continue;
      const va = a.out[j];
      const vb = b.out[j];
      if (va < 0 || vb < 0) sum += 2;
      else {
        const d = Math.abs(va - vb);
        if (d > 1 / 16 + 1e-6) sum += d;
      }
    }
    steps.push(sum / (scale * scale));
  }
  return steps;
}

function surfacePoints(shape, spacing = 0.6) {
  return sampleShape(shape, Math.max(16, Math.round(TAU / Math.max(0.05, spacing / Math.max(1, shape.r ?? 3)))));
}

function sphereOf(points) {
  const c = [0, 0, 0];
  for (const p of points) for (let i = 0; i < 3; i++) c[i] += p[i] / Math.max(1, points.length);
  let r = 0;
  for (const p of points) r = Math.max(r, Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]));
  return { c, r };
}

function familiesOf(T, { sweep, clips }) {
  const groups = T.groups;
  const out = [];
  const roots = groups.filter((g) => g.kind === "turn" && !g.parent);
  if (sweep) out.push({ name: "sweep", frame: 0.3, periodic: true, unit: "°", ...sweep });
  else if (roots.length) out.push({ name: "sweep", at: (x) => Object.fromEntries(roots.map((g) => [g.name, x])), from: 0, to: 360, frame: 0.3, periodic: true, unit: "°" });
  for (const clip of clips ?? []) out.push({ from: 0, frame: 1 / 60, unit: "s", ...clip });
  return out;
}

function samplesOf(family, frame) {
  const xs = [];
  const count = Math.max(1, Math.round((family.to - family.from) / frame));
  for (let i = 0; i < count + (family.periodic ? 0 : 1); i++) xs.push(family.from + i * frame);
  return xs;
}

const ORBIT_LINES = { separation: null, unseparated: "order", cycles: "order", forced: "order", swaps: "order", layers: "depth", rigid: "rigid", swept: "swept", posed: "posed", folds: "routes", self: "routes", crowding: "routes", endon: "routes", coverage: "coverage", depth: "depth", continuity: "continuity" };
const ORBIT_CHECKS = ["order", "rigid", "swept", "posed", "routes", "coverage", "continuity", "depth"];

export function orbit(T, R, P, options = {}) {
  const started = Date.now();
  const { sweep = null, clips = [], poses = null, random = 2000, step = 0.05, crowd = [], cover = [], fittings = [], depthAngles = 36, depthEvents = 36, quick = false, log = null, routeStates = 240, posedStep = null, only = null } = options;
  const say = (text) => log && log(text);
  const unknown = (only ?? []).filter((name) => !ORBIT_CHECKS.includes(name));
  if (unknown.length) throw new Error(`orbit: unknown check ${unknown.join(", ")} in only (the checks are ${ORBIT_CHECKS.join(", ")})`);
  const wants = (name) => !only || only.includes(name);
  const data = T.data;
  const scene = TURN.prepare(data, { unit: 1 });
  const runs = scene.runs;
  const groups = T.groups;
  const E = ((P.elevation ?? 30) * Math.PI) / 180;
  const result = { separation: [], unseparated: [], cycles: [], forced: [], swaps: [], layers: [], rigid: [], swept: [], posed: [], folds: [], self: [], crowding: [], endon: [], coverage: [], depth: [], continuity: [], notes: [], stats: {}, quick, skipped: Object.keys(ORBIT_LINES).filter((line) => ORBIT_LINES[line] && !wants(ORBIT_LINES[line])) };
  const families = familiesOf(T, { sweep, clips });
  const extra = typeof poses === "function" ? poses() : Array.isArray(poses) ? poses : [];
  const frameOf = (family) => (quick ? family.frame * (family.periodic ? Math.max(1, 1 / family.frame) : 3) : family.frame);
  const label = (family, x) => `${family.name} ${x.toFixed(3)}${family.unit ?? ""}`;
  const emitAll = (values, frames = runs.map((run) => TURN.frame(run))) => {
    const cams = TURN.cams(scene, values);
    for (const run of runs) TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[run.index]);
    return { cams, frames };
  };
  say("separation");
  const allMargins = T.built?.report?.margins ?? [];
  for (const m of allMargins) if (m.margin < -0.08) result.separation.push({ a: m.a, b: m.b, margin: m.margin });
  result.stats.planes = allMargins.length;
  result.stats.tightest = allMargins.length ? Math.min(...allMargins.map((m) => m.margin)) : 0;
  const relationOf = (layer, i, j, cams) => {
    const plan = layer.planar;
    for (let p = 0; p < plan.length; p += 5)
      if ((plan[p] === i && plan[p + 1] === j) || (plan[p] === j && plan[p + 1] === i)) {
        const g = runs[plan[p]].group;
        const V = g >= 0 ? cams[g].V : cams.world.V;
        const s = plan[p + 2] * V[0] + plan[p + 3] * V[1] + plan[p + 4] * V[2];
        return plan[p] === i ? s : -s;
      }
    const a = runs[i];
    const b = runs[j];
    const ca = a.group >= 0 ? cams[a.group] : cams.world;
    const cb = b.group >= 0 ? cams[b.group] : cams.world;
    const hit = TURN.gjk(a.cloud, ca, b.cloud, cb, null, 32, TURN.hintsOf(a, ca, b, cb, cams.world));
    const V = cams.world.V;
    return hit.n[0] * V[0] + hit.n[1] * V[1] + hit.n[2] * V[2];
  };
  const pairsOf = (layer) => {
    if (layer._pairs) return layer._pairs;
    const list = [];
    const seen = new Set();
    for (let p = 0; p < layer.planar.length; p += 5) list.push([layer.planar[p], layer.planar[p + 1]]);
    for (let p = 0; p < layer.dynamic.length; p += 2) list.push([layer.dynamic[p], layer.dynamic[p + 1]]);
    for (let p = 0; p < (layer.framed ? layer.framed.length : 0); p += 6) list.push([layer.framed[p], layer.framed[p + 1]]);
    for (const gp of layer.groupPairs ?? []) for (const i of gp.a) for (const j of gp.b) list.push([i, j]);
    for (const [i, j] of list) seen.add(i * 65536 + j);
    for (const i of layer.free) for (const j of layer.parts) {
      if (i === j || runs[i].group === runs[j].group) continue;
      const key = Math.min(i, j) * 65536 + Math.max(i, j);
      if (seen.has(key)) continue;
      seen.add(key);
      list.push([Math.min(i, j), Math.max(i, j)]);
    }
    layer._pairs = list;
    return list;
  };
  const checkState = (values, tag, previous, record) => {
    const { cams, frames } = emitAll(values);
    const orders = [];
    scene.layers.forEach((layer, index) => {
      const stats = {};
      const order = TURN.order(layer, frames, cams, previous ? previous[index] : null, stats, scene);
      orders.push(order);
      if (stats.forced) result.forced.push({ layer: layer.name, state: tag, forced: stats.forced });
      const framed = layer.framed ?? [];
      for (let p = 0; p < framed.length; p += 6) {
        const i = framed[p];
        const j = framed[p + 1];
        if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
        const g = framed[p + 2];
        const R = g >= 0 ? cams[g].R : EYE;
        const n = [R[0] * framed[p + 3] + R[1] * framed[p + 4] + R[2] * framed[p + 5], R[3] * framed[p + 3] + R[4] * framed[p + 4] + R[5] * framed[p + 5], R[6] * framed[p + 3] + R[7] * framed[p + 4] + R[8] * framed[p + 5]];
        const extent = (run) => {
          const c = run.group >= 0 ? cams[run.group] : cams.world;
          let lo = Infinity;
          let hi = -Infinity;
          for (let q = 0; q < run.cloud.length; q += 3) {
            const x = run.cloud[q];
            const y = run.cloud[q + 1];
            const z = run.cloud[q + 2];
            const v = n[0] * (c.R[0] * x + c.R[1] * y + c.R[2] * z + c.t[0]) + n[1] * (c.R[3] * x + c.R[4] * y + c.R[5] * z + c.t[1]) + n[2] * (c.R[6] * x + c.R[7] * y + c.R[8] * z + c.t[2]);
            if (v < lo) lo = v;
            if (v > hi) hi = v;
          }
          return [lo, hi];
        };
        const margin = extent(runs[j])[0] - extent(runs[i])[1];
        if (margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: runs[i].name, b: runs[j].name, margin, framed: true });
      }
      for (const gp of layer.groupPairs ?? []) {
        const R = gp.frame >= 0 ? cams[gp.frame].R : EYE;
        const nw = [R[0] * gp.n[0] + R[1] * gp.n[1] + R[2] * gp.n[2], R[3] * gp.n[0] + R[4] * gp.n[1] + R[5] * gp.n[2], R[6] * gp.n[0] + R[7] * gp.n[1] + R[8] * gp.n[2]];
        const along = (list, high) => {
          let best = high ? -Infinity : Infinity;
          for (const index of list) {
            const run = runs[index];
            const c = run.group >= 0 ? cams[run.group] : cams.world;
            for (let q = 0; q < run.cloud.length; q += 3) {
              const x = run.cloud[q];
              const y = run.cloud[q + 1];
              const z = run.cloud[q + 2];
              const v = nw[0] * (c.R[0] * x + c.R[1] * y + c.R[2] * z + c.t[0]) + nw[1] * (c.R[3] * x + c.R[4] * y + c.R[5] * z + c.t[1]) + nw[2] * (c.R[6] * x + c.R[7] * y + c.R[8] * z + c.t[2]);
              best = high ? Math.max(best, v) : Math.min(best, v);
            }
          }
          return best;
        };
        const margin = along(gp.b, false) - along(gp.a, true);
        if (margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: runs[gp.a[0]].name, b: runs[gp.b[0]].name, margin, framed: true });
      }
      const n = layer.parts.length;
      const local = layer.local;
      const adj = Array.from({ length: n }, () => []);
      const indeg = new Int32Array(n);
      for (const [i, j] of pairsOf(layer)) {
        if (TURN.disjoint(frames[i], frames[j], 0.1)) continue;
        const ri = runs[i];
        const rj = runs[j];
        if (stats.unseparated && ri.group !== rj.group) {
          const ca = ri.group >= 0 ? cams[ri.group] : cams.world;
          const cb = rj.group >= 0 ? cams[rj.group] : cams.world;
          const hit = TURN.gjk(ri.cloud, ca, rj.cloud, cb, null, 32, TURN.hintsOf(ri, ca, rj, cb, cams.world));
          if (hit.margin < -0.08) result.unseparated.push({ layer: layer.name, state: tag, a: ri.name, b: rj.name, margin: hit.margin });
        }
        const s = relationOf(layer, i, j, cams);
        if (s < 1e-7 && s > -1e-7) continue;
        const from = s > 0 ? local.get(i) : local.get(j);
        const to = s > 0 ? local.get(j) : local.get(i);
        adj[from].push(to);
        indeg[to]++;
      }
      const queue = [];
      for (let v = 0; v < n; v++) if (!indeg[v]) queue.push(v);
      let seen = 0;
      while (queue.length) {
        const v = queue.pop();
        seen++;
        for (const w of adj[v]) if (--indeg[w] === 0) queue.push(w);
      }
      if (seen < n) {
        const stuck = [];
        for (let v = 0; v < n; v++) if (indeg[v] > 0) stuck.push(runs[layer.parts[v]].name);
        let split = null;
        for (const m of T.built?.report?.margins ?? []) if (stuck.includes(m.a) && stuck.includes(m.b) && (!split || m.margin > split.margin)) split = m;
        const plane = split && T.built.planesByName ? T.built.planesByName.get(`${split.a}|${split.b}`) : null;
        result.cycles.push({ layer: layer.name, state: tag, parts: stuck.slice(0, 8), split: plane ? { part: split.b, a: split.a, b: split.b, n: plane.n, d: plane.d } : null });
      }
    });
    if (record) record(cams, frames, orders);
    return { cams, frames, orders };
  };
  const swapsOf = (a, b) => {
    const list = [];
    scene.layers.forEach((layer, index) => {
      const pa = a.orders[index];
      const pb = b.orders[index];
      const rankA = new Map();
      pa.forEach((g, at) => rankA.set(g, at));
      const rankB = new Map();
      pb.forEach((g, at) => rankB.set(g, at));
      const moved = new Set();
      for (let at = 0; at < pb.length; at++) if (pa[at] !== pb[at]) moved.add(pb[at]);
      const moving = [...moved];
      for (let x = 0; x < moving.length; x++)
        for (const j of layer.parts) {
          const i = moving[x];
          if (i === j || (moved.has(j) && j < i)) continue;
          if ((rankA.get(i) < rankA.get(j)) === (rankB.get(i) < rankB.get(j))) continue;
          if (T.parts[i].route && T.parts[i].route === T.parts[j].route && Math.abs(T.parts[i].chunk - T.parts[j].chunk) <= 1) continue;
          const area = Math.min(overlapArea(runs[i], a.frames[i], runs[j], a.frames[j]), overlapArea(runs[i], b.frames[i], runs[j], b.frames[j]));
          list.push({ layer: layer.name, i, j, area });
        }
    });
    return list;
  };
  const clone = (frames) => frames.map((f) => ({ ...f, d: f.d.slice(), box: Float64Array.from(f.box), hull: Float64Array.from(f.hull), quadList: f.quadList, fill: undefined }));
  const flush = new Set();
  for (const m of T.built?.report?.margins ?? []) if (Math.abs(m.margin) < 0.05) flush.add(`${m.a}|${m.b}`);
  const flushOf = (a, b) => flush.has(`${a}|${b}`) || flush.has(`${b}|${a}`);
  const changes = [];
  let orderStates = 0;
  for (const family of wants("order") ? families : []) {
    const xs = samplesOf(family, frameOf(family));
    say(`order scan ${family.name}: ${xs.length} states`);
    let previous = null;
    const local = [];
    for (const x of xs) {
      const values = family.at(x);
      const tag = label(family, x);
      const state = checkState(values, tag, previous ? previous.orders : null);
      orderStates++;
      const snapshot = { orders: state.orders.map((o) => Int32Array.from(o)), frames: clone(state.frames), x };
      if (previous) {
        const swaps = swapsOf(previous, snapshot);
        for (const swap of swaps) {
          if (swap.area <= 0.25) continue;
          const layerIndex = scene.layers.findIndex((l) => l.name === swap.layer);
          const layer = scene.layers[layerIndex];
          const prior = previous.orders[layerIndex];
          const probe = (value) => {
            const { cams, frames } = emitAll(family.at(value));
            const order = TURN.order(layer, frames, cams, prior, {}, scene);
            const at = new Map();
            order.forEach((g, index) => at.set(g, index));
            return { first: at.get(swap.i) < at.get(swap.j), frames };
          };
          const start = probe(previous.x).first;
          let lo = previous.x;
          let hi = x;
          let frames = null;
          for (let it = 0; it < 16; it++) {
            const mid = (lo + hi) / 2;
            const next = probe(mid);
            if (next.first === start) lo = mid;
            else {
              hi = mid;
              frames = next.frames;
            }
          }
          if (!frames) frames = probe(hi).frames;
          const area = overlapArea(runs[swap.i], frames[swap.i], runs[swap.j], frames[swap.j]);
          const built = flushOf(runs[swap.i].name, runs[swap.j].name);
          if (area > (built ? 0.5 : 0.25)) result.swaps.push({ layer: swap.layer, a: runs[swap.i].name, b: runs[swap.j].name, area, at: `${family.name} ${f3(hi)}` });
        }
        if (swaps.length) local.push({ family, x, count: swaps.length, pairs: swaps });
      }
      previous = snapshot;
    }
    changes.push(...local);
    const share = Math.round((quick ? Math.min(200, random) : random) / families.length);
    let seed = 12345 + families.indexOf(family);
    const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
    const events = local.flatMap((c) => [c.x - (family.delta ?? (family.periodic ? 0.001 : family.frame / 50)), c.x + (family.delta ?? (family.periodic ? 0.001 : family.frame / 50))]);
    say(`order scan ${family.name}: ${share} random states, ${events.length} event states`);
    for (let i = 0; i < share; i++) {
      const x = family.from + rand() * (family.to - family.from);
      checkState(family.at(x), `random ${label(family, x)}`, null);
    }
    for (const x of events.slice(0, quick ? 100 : 4000)) checkState(family.at(x), `event ${label(family, x)}`, null);
    orderStates += share + Math.min(events.length, quick ? 100 : 4000);
  }
  for (const values of wants("order") ? extra.slice(0, quick ? 100 : extra.length) : []) checkState(values, `pose ${JSON.stringify(values).slice(0, 60)}`, null);
  result.stats.orderStates = orderStates + extra.length;
  result.stats.changes = changes.length;
  result.changes = changes.sort((a, b) => b.count - a.count).slice(0, 10).map((c) => ({ family: c.family.name, x: c.x, count: c.count, pairs: c.pairs.slice(0, 4).map((p) => `${runs[p.i].name} / ${runs[p.j].name}`), at: c.family.at }));
  say("rigid");
  const mounts = A.mountsOf(R.tubes, R.solids);
  if (wants("rigid")) {
  for (const c of A.clearances(R.tubes, R.solids, { mounts })) result.rigid.push({ kind: "clearance", text: `${c.kind} ${c.tube} × ${c.other} clearance ${f2(c.clearance)} < ${f2(c.need)}` });
  for (const e of A.terminals(R.tubes, R.solids, { mounts })) result.rigid.push({ kind: "terminal", text: `${e.tube} ${e.end} ${Number.isFinite(e.d) ? f2(e.d) : "∞"} from ${e.nearest}` });
  const held = A.supports(R.solids, R.tubes);
  for (const name of held.loose) result.rigid.push({ kind: "support", text: `loose ${name}` });
  for (const name of held.hanging ?? []) result.rigid.push({ kind: "support", text: `hanging ${name}` });
  for (const d of held.sunk) result.rigid.push({ kind: "support", text: `sunk ${d.a} × ${d.b} ${f2(d.depth)} deep` });
  for (const c of A.solidClearances(R.solids)) result.rigid.push({ kind: "solid", text: `${c.tube} × ${c.other} sunk ${f2(-c.clearance)}` });
  }
  say("swept");
  const liveShapes = [];
  for (const part of T.parts) for (const shape of part.shapes) liveShapes.push({ shape, part, group: part.group });
  const liveSet = new Set(liveShapes.map((entry) => entry.shape));
  const staticShapes = [];
  for (const item of R.items) for (const shape of item.shapes ?? []) if (!liveSet.has(shape)) staticShapes.push({ shape, item });
  const pointsCache = new Map();
  const pointsOf = (shape) => {
    if (!pointsCache.has(shape)) pointsCache.set(shape, surfacePoints(shape));
    return pointsCache.get(shape);
  };
  for (const g of wants("swept") ? groups : []) {
    if (g.kind !== "turn" || g.parent) continue;
    const mine = liveShapes.filter((entry) => entry.group === g);
    const slices = new Map();
    for (const { shape } of mine)
      for (const p of pointsOf(shape)) {
        const band = Math.floor(p[2] * 2);
        const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
        slices.set(band, Math.max(slices.get(band) ?? 0, r));
      }
    const flagged = [];
    for (const { shape, item } of staticShapes) {
      let worst = Infinity;
      for (const p of pointsOf(shape)) {
        const r = Math.hypot(p[0] - g.origin[0], p[1] - g.origin[1]);
        for (const band of [Math.floor(p[2] * 2), Math.floor(p[2] * 2 - 0.2), Math.floor(p[2] * 2 + 0.2)]) {
          const reach = slices.get(band);
          if (reach !== undefined) worst = Math.min(worst, r - reach);
        }
      }
      if (worst < -0.1) flagged.push({ shape, item });
    }
    for (const { shape, item } of flagged) {
      let deepest = 0;
      let where = 0;
      const count = quick ? 90 : 720;
      for (let i = 0; i < count; i++) {
        const angle = (i * 360) / count;
        const cams = TURN.cams(scene, { [g.name]: angle });
        const pose = poseFor(cams, g);
        const b = A.boundsOf(shape);
        for (const { shape: other } of mine)
          for (const p of pointsOf(other)) {
            const q = place(pose, p);
            if (q[0] < b.min[0] - 0.5 || q[0] > b.max[0] + 0.5 || q[1] < b.min[1] - 0.5 || q[1] > b.max[1] + 0.5 || q[2] < b.min[2] - 0.5 || q[2] > b.max[2] + 0.5) continue;
            const d = A.sdf(shape, q);
            if (d < deepest) {
              deepest = d;
              where = angle;
            }
          }
      }
      if (deepest < -0.1) result.swept.push({ text: `${item.name} × ${g.name} sweep: ${f2(-deepest)} deep at ${f2(where)}°` });
    }
  }
  say("posed");
  const posedStates = [];
  for (const family of families) {
    const frame = posedStep ?? (family.periodic ? (quick ? 4 : 0.5) : family.frame * (quick ? 4 : 1));
    for (const x of samplesOf(family, frame)) posedStates.push({ values: family.at(x), tag: label(family, x) });
  }
  for (const values of extra.slice(0, quick ? 40 : extra.length)) posedStates.push({ values, tag: "pose" });
  const entries = [
    ...liveShapes.map((entry) => ({ ...entry, kind: "live", sphere: sphereOf(pointsOf(entry.shape)) })),
    ...staticShapes.map((entry) => ({ shape: entry.shape, part: { name: entry.item.name }, group: "static", item: entry.item, kind: "static", sphere: sphereOf(pointsOf(entry.shape)) })),
  ];
  const posedSeen = new Set();
  const movingGroups = groups.filter((g) => !(g.kind === "turn" && !g.parent));
  const needsStatic = movingGroups.length > 0;
  for (const { values, tag } of wants("posed") ? posedStates : []) {
    const cams = TURN.cams(scene, values);
    const placed = entries.map((entry) => {
      if (entry.kind === "static") return { entry, c: entry.sphere.c, pose: null };
      const pose = poseFor(cams, entry.group);
      return { entry, c: entry.group ? place(pose, entry.sphere.c) : entry.sphere.c, pose: entry.group ? pose : null };
    });
    placed.sort((a, b) => a.c[0] - a.entry.sphere.r - (b.c[0] - b.entry.sphere.r));
    for (let i = 0; i < placed.length; i++) {
      const A0 = placed[i];
      const reachA = A0.c[0] + A0.entry.sphere.r + 0.5;
      for (let j = i + 1; j < placed.length; j++) {
        const B0 = placed[j];
        if (B0.c[0] - B0.entry.sphere.r > reachA) break;
        const a = A0.entry;
        const b = B0.entry;
        if (a.group === b.group) continue;
        if (a.kind === "static" && b.kind === "static") continue;
        if ((a.kind === "static" || b.kind === "static") && !needsStatic) continue;
        if (a.kind === "static" || b.kind === "static") {
          const live = a.kind === "static" ? b : a;
          if (!live.group || (live.group.kind === "turn" && !live.group.parent)) continue;
        }
        if (a.shape.kind === "tube" && b.shape.kind === "tube") continue;
        if (Math.hypot(A0.c[0] - B0.c[0], A0.c[1] - B0.c[1], A0.c[2] - B0.c[2]) > a.sphere.r + b.sphere.r + 0.5) continue;
        const key = `${a.part.name}|${b.part.name}`;
        if (posedSeen.has(key)) continue;
        const pa = A0.pose ?? IDENTITY;
        const pb = B0.pose ?? IDENTITY;
        let deepest = 0;
        for (const p of pointsOf(a.shape)) {
          const d = A.sdf(b.shape, unplace(pb, place(pa, p)));
          if (d < deepest) deepest = d;
        }
        for (const p of pointsOf(b.shape)) {
          const d = A.sdf(a.shape, unplace(pa, place(pb, p)));
          if (d < deepest) deepest = d;
        }
        const limit = a.shape.kind === "tube" || b.shape.kind === "tube" ? -0.1 : -0.5;
        if (deepest < limit) {
          posedSeen.add(key);
          result.posed.push({ text: `${a.part.name} × ${b.part.name} through ${f2(-deepest)} at ${tag}` });
        }
      }
    }
  }
  result.stats.posedStates = posedStates.length;
  say("folds, self, crowding, endon");
  const routes = T.routes ?? [];
  const turnGroups = groups.filter((g) => g.kind === "turn" && !g.parent);
  const tubesAll = R.tubes;
  const linkMap = A.linksOf(tubesAll, mounts);
  const routeSample = [];
  {
    const all = posedStates.filter((s) => s.tag !== "pose");
    const stride = Math.max(1, Math.floor(all.length / (quick ? 48 : routeStates)));
    for (let i = 0; i < all.length; i += stride) routeSample.push(all[i]);
    for (const values of extra.slice(0, 24)) routeSample.push({ values, tag: "pose" });
  }
  const foldSeen = new Set();
  const crowdSeen = new Set();
  const lo = (E * 180) / Math.PI;
  for (const route of wants("routes") ? routes : []) {
    const g = route.group;
    const root = g && g.kind === "turn" && !g.parent;
    const records = R.routes.filter((r) => r.name === route.name);
    const groupParts = T.parts.filter((part) => part.group === g);
    const itemsAt = (pose) => groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
    const views = [];
    if (!g) views.push({ P, pose: null, tag: "rest" });
    else if (root) {
      const count = quick ? 72 : 720;
      for (let i = 0; i < count; i++) {
        const x = (i * 360) / count;
        views.push({ P: { ...P, azimuth: (P.azimuth ?? 45) - x }, pose: null, tag: `${f2(x)}°` });
      }
    } else for (const { values, tag } of routeSample) views.push({ P, pose: poseFor(TURN.cams(scene, values), g), tag });
    for (const view of views) {
      const posedRecords = view.pose ? records.map((r) => ({ ...r, points: r.points.map((p) => place(view.pose, p)) })) : records;
      const items = itemsAt(view.pose);
      for (const fold of A.folds(posedRecords, view.P, { items })) {
        const key = `${fold.name}|${fold.kind}`;
        if (foldSeen.has(key)) continue;
        foldSeen.add(key);
        result.folds.push({ text: `${fold.name} ${fold.kind} at ${view.tag}: turns ${fold.flatTurn.toFixed(0)}° on screen${fold.kind === "pinch" ? ` round a ${f2(fold.radius)} px bend` : ` for ${fold.realTurn.toFixed(0)}° in 3D`}` });
      }
      for (const s of A.selfOverlaps(items.filter((item) => item.route === route.name), view.P)) {
        const key = `self|${s.name}`;
        if (foldSeen.has(key)) continue;
        foldSeen.add(key);
        result.self.push({ text: `${s.name} folds back at ${view.tag}: radius ${f2(s.radius)} px under its half-width ${f2(s.width)} px` });
      }
    }
    const dense = route.world;
    const total = G.pathLength(dense);
    const stations = G.resample(dense, 0.25);
    const rotations = !g || root || g.upright ? [null] : routeSample.map(({ values }) => poseFor(TURN.cams(scene, values), g).R);
    const bad = [];
    let walked = 0;
    for (let i = 1; i < stations.length - 1; i++) {
      walked += G.len3(G.sub3(stations[i], stations[i - 1]));
      if (route.gaps.some(([a, b]) => walked >= a - 1e-6 && walked <= b + 1e-6)) continue;
      if (fittings.some(([name, a, b]) => name === route.name && walked >= a && walked <= b)) continue;
      const t0 = G.unit3(G.sub3(stations[i + 1], stations[i - 1]));
      for (const Rm of rotations) {
        const t = Rm ? apply(Rm, t0) : t0;
        const phi = (Math.asin(Math.max(-1, Math.min(1, t[2]))) * 180) / Math.PI;
        if ((phi > lo - 15 && phi < lo + 15) || (phi > -lo - 15 && phi < -lo + 15)) {
          bad.push({ at: walked, phi });
          break;
        }
      }
    }
    if (bad.length) result.endon.push({ text: `${route.name} points within 15° of the view along ${f2(bad[0].at)}–${f2(bad[bad.length - 1].at)} of ${f2(total)} (slope ${bad[0].phi.toFixed(1)}°): bend flat or inside a fitting` });
  }
  const crowdViews = [];
  for (const g of turnGroups) {
    const count = quick ? 24 : 72;
    for (let i = 0; i < count; i++) crowdViews.push({ group: g, P: { ...P, azimuth: (P.azimuth ?? 45) - (i * 360) / count }, tag: `${f2((i * 360) / count)}°`, values: null });
  }
  const others = [...new Set(routes.map((route) => route.group).filter((g) => g && !(g.kind === "turn" && !g.parent)))];
  for (const g of others) for (const { values, tag } of routeSample.slice(0, quick ? 24 : 72)) crowdViews.push({ group: g, P, tag, values });
  for (const view of wants("routes") ? crowdViews : []) {
    const g = view.group;
    const pose = view.values ? poseFor(TURN.cams(scene, view.values), g) : null;
    const mineRoutes = R.routes.filter((r) => routes.some((route) => route.name === r.name && route.group === g)).map((r) => (pose ? { ...r, points: r.points.map((p) => place(pose, p)) } : r));
    if (!mineRoutes.length) continue;
    const groupParts = T.parts.filter((part) => part.group === g);
    const items = groupParts.map((part) => ({ name: part.name, route: part.route, shapes: pose ? part.shapes.map((shape) => posed(shape, pose)) : part.shapes }));
    const rods = A.slenderOf(items.flatMap((item) => item.shapes).filter((shape) => shape.kind !== "tube"));
    for (const c of A.crowding(mineRoutes, view.P, { links: linkMap, items, allow: crowd, rods })) {
      const key = [c.a, c.b, c.where].join("|");
      if (crowdSeen.has(key)) continue;
      crowdSeen.add(key);
      result.crowding.push({ text: `${c.a} ${c.where} × ${c.b} at ${view.tag}: outlines ${c.gap < 0 ? `overlap ${f2(-c.gap)}` : `${f2(c.gap)} apart`} px` });
    }
  }
  say("coverage");
  const rest = emitAll(Object.fromEntries(groups.map((g) => [g.name, g.kind === "free" ? null : 0])));
  const coverItems = T.parts.map((part) => {
    const run = runs[part.id];
    const f = rest.frames[part.id];
    const outline = run.kind === "fixed" ? part.svg : run.kind === "tube" ? f.d[run.role.body] : f.d[run.role.fill];
    return { name: part.name, svg: `<path d="${outline}"/>`, shapes: part.shapes };
  });
  for (const c of wants("coverage") ? A.coverage(coverItems, P, { allow: cover }) : []) result.coverage.push({ text: `${c.name} its shapes cover ${Math.round(c.share * 100)}% of its drawing` });
  say("continuity");
  if (wants("continuity")) {
    TURN.exact(true);
    try {
      continuity(T, scene, runs, groups, { step, quick, result, say, families, frameOf });
    } finally {
      TURN.exact(false);
    }
  }
  say("depth oracle");
  const depthStates = [];
  const first = families[0];
  if (first) for (let i = 0; i < (quick ? 6 : depthAngles); i++) {
    const x = first.from + ((first.to - first.from) * i) / (quick ? 6 : depthAngles);
    depthStates.push({ values: first.at(x), tag: label(first, x) });
  }
  for (const family of families.slice(1)) for (let i = 0; i <= (quick ? 3 : 12); i++) {
    const x = family.from + ((family.to - family.from) * i) / (quick ? 3 : 12);
    depthStates.push({ values: family.at(x), tag: label(family, x) });
  }
  for (const change of result.changes.slice(0, quick ? 4 : depthEvents)) depthStates.push({ values: change.at(change.x), tag: `${change.family} ${f3(change.x)}` });
  let checked = 0;
  for (const { values, tag } of wants("depth") ? depthStates : []) {
    const state = checkState(values, `depth ${tag}`, null);
    scene.layers.forEach((layer, index) => {
      const items = [];
      for (const global of layer.parts) {
        const part = T.parts[global];
        const pose = poseFor(state.cams, part.group);
        items.push({ name: part.name, global, shapes: part.shapes.map((shape) => posed(shape, pose)), route: part.route, chunk: part.chunk });
      }
      const ranks = new Map((T.order.length ? T.order : T.layers.map((l) => l.name)).map((name, at) => [name, at]));
      for (const item of R.items) if (item.layer && ranks.has(item.layer) && item.shapes && item.shapes.length) items.push({ name: item.name, static: ranks.get(item.layer) < ranks.get(layer.name) ? "below" : "above", shapes: item.shapes });
      const overlaps = A.overlapsOf(items, P, { step: quick ? 0.5 : 0.25, skip: (a, b) => A.sameRun(a, b) || (a.static && b.static) });
      for (const pair of overlaps.pairs) {
        const a = items[pair.a];
        const b = items[pair.b];
        if (!a.static === !b.static) continue;
        const sideIndex = a.static ? 0 : 1;
        const fixed = a.static ? a : b;
        const moving = a.static ? b : a;
        const staticFront = pair.front[sideIndex].area >= overlaps.least;
        const liveFront = pair.front[1 - sideIndex].area >= overlaps.least;
        if (fixed.static === "below" && staticFront) result.layers.push({ text: `${fixed.name} (under ${layer.name}) is in front of ${moving.name} at ${tag}: ${f2(pair.front[sideIndex].area)} px²` });
        if (fixed.static === "above" && liveFront) result.layers.push({ text: `${fixed.name} (over ${layer.name}) is behind ${moving.name} at ${tag}: ${f2(pair.front[1 - sideIndex].area)} px²` });
      }
      const rank = new Map();
      state.orders[index].forEach((global, at) => rank.set(global, at));
      for (const pair of overlaps.pairs) {
        if (items[pair.a].static || items[pair.b].static) continue;
        checked++;
        const aFront = pair.front[0].area >= overlaps.least;
        const bFront = pair.front[1].area >= overlaps.least;
        if (aFront === bFront) continue;
        const front = items[aFront ? pair.a : pair.b];
        const backItem = items[aFront ? pair.b : pair.a];
        if (rank.get(front.global) < rank.get(backItem.global)) result.depth.push({ text: `${layer.name} at ${tag}: ${front.name} is in front of ${backItem.name} but drawn first (${f2(pair.front[aFront ? 0 : 1].area)} px²)` });
      }
    });
  }
  result.stats.depthStates = depthStates.length;
  result.stats.depthPairs = checked;
  result.stats.seconds = (Date.now() - started) / 1000;
  return result;
}

function continuity(T, scene, runs, groups, { step, quick, result, say, families = [], frameOf = (f) => f.frame }) {
  const stepSize = quick ? step * 10 : step;
  const measures = [];
  const worst = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
  const failures = [];
  const k = scene.P.scale * Math.sqrt(1.6);
  const sweeps = [];
  for (const g of groups) {
    if (g.loose) continue;
    if (g.kind === "turn") sweeps.push({ group: g, from: 0, to: 360, unit: "°" });
    else if (g.kind === "hinge") sweeps.push({ group: g, from: g.range[0], to: g.range[1], unit: "°" });
    else sweeps.push({ group: g, from: 0, to: g.travel, unit: "" });
  }
  for (const sweep of sweeps) {
    const g = sweep.group;
    const parts = runs.filter((run) => run.group === g.index);
    if (!parts.length) continue;
    const span = sweep.to - sweep.from;
    const delta = g.kind === "slide" ? Math.max(0.01, span / 400) : stepSize;
    const count = Math.max(1, Math.round(span / delta));
    const reach = parts.map((run) => {
      let r = 0;
      const part = T.parts[run.index];
      for (const p of part.samples) r = Math.max(r, g.kind === "slide" ? 0 : Math.hypot(p[0], p[1]));
      return r;
    });
    const valuesAt = (x) => ({ [g.name]: x });
    const emitPart = (run, x, f) => {
      const cams = TURN.cams(scene, valuesAt(x));
      TURN.emit(run, cams[g.index], f);
      return f;
    };
    say(`continuity ${g.name}: ${count} steps of ${delta}${sweep.unit} over ${parts.length} parts`);
    const snap = (run, f) => {
      const roles = run.roles;
      const out = { faces: [], sheets: [], dots: [], planes: [], fade: {}, outline: null, levels: f.levels ?? 0 };
      roles.forEach((role, r) => {
        if (role.type === "face") out.faces.push([f.d[r] ? areaOfPath(f.d[r]) : 0, f.q[r], Boolean(f.d[r])]);
        else if (role.type === "path" && /^s[0-4]$/.test(role.name)) out.sheets.push([areaOfPath(f.d[r]), (f.d[r].match(/M/g) ?? []).length]);
        else if (role.type === "dot") out.dots.push(f.o[r]);
        else if (role.type === "plane") out.planes.push(f.o[r]);
      });
      for (const [prefix, set] of Object.entries(f.fade ?? {})) {
        const map = new Map();
        set.key.forEach((key, i) => map.set(key, Math.round(set.alpha[i] * TURN.levels) / TURN.levels));
        out.fade[prefix] = map;
      }
      const edge = run.role.fill !== undefined ? f.d[run.role.fill] : run.role.body !== undefined ? f.d[run.role.body] : "";
      out.outline = run.kind === "fixed" ? [Array.from({ length: f.hullN }, (_, i) => [f.hull[2 * i], f.hull[2 * i + 1]])] : pathPolys(edge);
      out.slots = run.slots;
      return out;
    };
    const compare = (run, a, b, dx, reachPx) => {
      const m = { q: 0, birth: 0, dot: 0, fade: 0, motion: 0, merge: 0 };
      for (let i = 0; i < Math.min(a.faces.length, b.faces.length); i++) {
        const [areaA, qa, onA] = a.faces[i];
        const [areaB, qb, onB] = b.faces[i];
        if (onA && onB) m.q = Math.max(m.q, (Math.abs(qa - qb) * 16) / TURN.q);
        if (onA !== onB) m.birth = Math.max(m.birth, onA ? areaA : areaB);
        m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
      }
      for (let i = 0; i < Math.min(a.sheets.length, b.sheets.length); i++) {
        const [areaA, countA] = a.sheets[i];
        const [areaB, countB] = b.sheets[i];
        if (!countA !== !countB) m.birth = Math.max(m.birth, Math.abs(areaA - areaB));
        m.area = Math.max(m.area ?? 0, Math.abs(areaA - areaB));
      }
      for (let i = 0; i < Math.min(a.dots.length, b.dots.length); i++) m.dot = Math.max(m.dot, Math.abs(a.dots[i] - b.dots[i]));
      for (let i = 0; i < Math.min(a.planes.length, b.planes.length); i++) m.dot = Math.max(m.dot, Math.abs(a.planes[i] - b.planes[i]));
      for (const prefix of new Set([...Object.keys(a.fade), ...Object.keys(b.fade)])) {
        const A0 = a.fade[prefix] ?? new Map();
        const B0 = b.fade[prefix] ?? new Map();
        for (const key of new Set([...A0.keys(), ...B0.keys()])) m.fade = Math.max(m.fade, Math.abs((A0.get(key) ?? 0) - (B0.get(key) ?? 0)));
      }
      const slots = Math.max(run.slots.bevel ?? 0, run.slots.ribs ?? 0);
      if (slots && (a.levels > slots || b.levels > slots)) m.merge = Math.max(a.levels, b.levels) - slots;
      if (a.outline.length && b.outline.length && a.outline[0].length && b.outline[0].length) m.motion = Math.max(0, hausdorffish(a.outline[0], b.outline[0]) - (1.5 * reachPx * dx + 0.05));
      return m;
    };
    const frames = parts.map((run) => TURN.frame(run));
    let previous = parts.map((run, i) => snap(run, emitPart(run, sweep.from, frames[i])));
    for (let c = 1; c <= count; c++) {
      const x = sweep.from + c * delta;
      parts.forEach((run, i) => {
        const current = snap(run, emitPart(run, x, frames[i]));
        const reachPx = g.kind === "slide" ? k : (reach[i] * k * Math.PI) / 180;
        const m = compare(run, previous[i], current, g.kind === "slide" ? delta : delta, reachPx);
        for (const key of Object.keys(worst)) worst[key] = Math.max(worst[key], m[key] ?? 0);
        const scale = delta / step;
        if (m.q > Math.ceil(scale) || m.birth > 2 * scale || m.dot > 0.0105 * scale || m.fade > Math.ceil(scale) / 32 + 0.005 || m.merge > 0 || m.motion > 0) failures.push({ part: run.name, group: g.name, at: x, m });
        measures.push({ run, x: x - delta, dx: delta, value: m.birth + m.motion * 4 + (m.area ?? 0), motion: m.motion, birth: m.birth, valuesAt, reachPx });
        previous[i] = current;
      });
    }
  }
  measures.sort((a, b) => b.value - a.value);
  let halvingFails = 0;
  let halvingChecked = 0;
  const halving = (run, at, x, dx) => {
    const emitAt = (value) => {
      const f = TURN.frame(run);
      const cams = TURN.cams(scene, at(value));
      TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, f);
      return f;
    };
    const frames = Array.from({ length: 9 }, (_, j) => emitAt(x + (dx * j) / 8));
    const measure = (scale) => {
      const eighth = Math.max(...visibleChange(run, frames, scale));
      const quarter = Math.max(...visibleChange(run, [0, 2, 4, 6, 8].map((j) => frames[j]), scale));
      const half = Math.max(...visibleChange(run, [frames[0], frames[4], frames[8]], scale));
      const full = visibleChange(run, [frames[0], frames[8]], scale)[0];
      return { full, half, quarter, eighth, fails: full >= 2 && half > 0.6 * full && quarter > 0.6 * half && eighth > 0.6 * quarter && eighth >= 2 };
    };
    const coarse = measure(4);
    return coarse.fails ? measure(16) : coarse;
  };
  const seen = new Set();
  for (const entry of measures.slice(0, quick ? 40 : 400)) {
    if (!(entry.value > 1e-6)) break;
    const key = `${entry.run.index}|${entry.x.toFixed(4)}`;
    if (seen.has(key)) continue;
    seen.add(key);
    halvingChecked++;
    const g = groups[entry.run.group];
    const h = halving(entry.run, entry.valuesAt, entry.x, entry.dx);
    if (!h.fails) continue;
    halvingFails++;
    failures.push({ part: entry.run.name, group: g.name, at: entry.x, m: { halving: `${f2(h.full)} tone·px² step stays ${f2(h.half)}, ${f2(h.quarter)} and ${f2(h.eighth)} at a half, a quarter and an eighth of the step` } });
  }
  const areaRoles = (run) => run.roles.map((role, r) => (role.type === "face" || /^s[0-4]$/.test(role.name) || (run.kind === "tube" && role.name === "body") ? r : -1)).filter((r) => r >= 0);
  const clipSteps = [];
  for (const family of families) {
    if (family.periodic) continue;
    const xs = samplesOf(family, frameOf(family));
    say(`continuity ${family.name}: ${xs.length} frames over ${runs.length} parts`);
    const frames = runs.map((run) => TURN.frame(run));
    let previous = null;
    for (const x of xs) {
      const cams = TURN.cams(scene, family.at(x));
      const current = runs.map((run, index) => {
        TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, frames[index]);
        return areaRoles(run).map((r) => areaOfPath(frames[index].d[r]));
      });
      if (previous)
        runs.forEach((run, index) => {
          let most = 0;
          let role = -1;
          current[index].forEach((v, k) => {
            const d = Math.abs(v - previous[index][k]);
            if (d > most) {
              most = d;
              role = k;
            }
          });
          if (most >= 0.5) clipSteps.push({ family, run, x: x - frameOf(family), dx: frameOf(family), role, value: most });
        });
      previous = current;
    }
  }
  clipSteps.sort((a, b) => b.value - a.value);
  let clipChecked = 0;
  for (const entry of clipSteps.slice(0, quick ? 60 : 400)) {
    clipChecked++;
    const h = halving(entry.run, entry.family.at, entry.x, entry.dx);
    if (!h.fails) continue;
    halvingFails++;
    failures.push({ part: entry.run.name, group: entry.family.name, at: entry.x, m: { halving: `${f2(h.full)} tone·px² step stays ${f2(h.half)}, ${f2(h.quarter)} and ${f2(h.eighth)} at a half, a quarter and an eighth of the frame` } });
  }
  result.continuity = failures;
  result.stats.continuity = { worst, halvingChecked: halvingChecked + clipChecked, halvingFails, measures: measures.length + clipSteps.length };
}

export function orbitReport(result) {
  const lines = [];
  const skipped = (name, items) => (result.skipped ?? []).includes(name) && !items.length;
  const list = (name, items, text) => {
    if (skipped(name, items)) return lines.push(`${name}: skipped`);
    lines.push(`${name}: ${items.length}`);
    for (const item of items.slice(0, 40)) lines.push(`  ${text(item)}`);
  };
  list("separation", result.separation, (m) => `interlock ${m.a} × ${m.b} ${f2(m.margin)}: split, flats or a flush contact`);
  list("unseparated", result.unseparated, (m) => `${m.state}  ${m.a} × ${m.b} margin ${f2(m.margin)} while their hulls overlap${m.framed ? " (framed plane: the build's poses() missed this motion; add it to poses())" : ""}`);
  const cycleGroups = new Map();
  for (const c of result.cycles) {
    const key = `${c.layer}|${c.parts.join(", ")}`;
    if (!cycleGroups.has(key)) cycleGroups.set(key, { ...c, first: c.state, last: c.state, count: 0 });
    const g = cycleGroups.get(key);
    g.last = c.state;
    g.count++;
  }
  lines.push(skipped("cycles", result.cycles) ? "cycles: skipped" : `cycles: ${result.cycles.length}`);
  for (const g of [...cycleGroups.values()].slice(0, 20)) lines.push(`  ${g.layer}: ${g.parts.join(", ")} in ${g.count} state${g.count === 1 ? "" : "s"} from ${g.first} to ${g.last}: ${g.split ? `split ${g.split.part} on n = (${g.split.n.map((v) => v.toFixed(3)).join(", ")}), d = ${g.split.d.toFixed(3)} (the plane of the cycle edge with the largest margin, ${g.split.a} × ${g.split.b})` : "split one of them (T.split) on the plane of the cycle edge with the largest margin"}`);
  list("forced", result.forced, (c) => `${c.state}  ${c.layer}: ${c.forced} forced pick${c.forced === 1 ? "" : "s"}`);
  list("swaps", result.swaps, (s) => `${s.layer}: ${s.a} / ${s.b} swap at ${s.at} while overlapping ${f2(s.area)} px²`);
  list("layers", result.layers, (s) => s.text);
  list("rigid", result.rigid, (s) => `${s.kind}  ${s.text}`);
  list("swept", result.swept, (s) => s.text);
  list("posed", result.posed, (s) => s.text);
  list("folds", result.folds, (s) => s.text);
  list("self", result.self, (s) => s.text);
  list("crowding", result.crowding, (s) => s.text);
  list("endon", result.endon, (s) => s.text);
  list("coverage", result.coverage, (s) => s.text);
  list("depth", result.depth, (s) => s.text);
  list("continuity", result.continuity, (c) => `${c.part} (${c.group}) at ${f3(c.at)}: ${Object.entries(c.m).filter(([, v]) => typeof v === "string" || v > 0).map(([key, v]) => `${key} ${typeof v === "string" ? v : f3(v)}`).join(", ")}`);
  const s = result.stats;
  const w = s.continuity?.worst ?? {};
  if (!skipped("continuity", result.continuity)) lines.push(`worst tone step ${w.q ?? 0} · worst birth ${f2(w.birth ?? 0)} px² · worst dot α step ${f3(w.dot ?? 0)} · worst faded α step ${f3(w.fade ?? 0)} · merges ${w.merge ?? 0} · outline motion over bound ${f3(w.motion ?? 0)} px · halving ${s.continuity?.halvingFails ?? 0} of ${s.continuity?.halvingChecked ?? 0}`);
  lines.push(`order changes: ${s.changes ?? 0} states with swaps · ${s.orderStates ?? 0} order states · ${s.posedStates ?? 0} posed states · ${s.planes ?? 0} planes (tightest margin ${f3(s.tightest ?? 0)}) · depth oracle ${s.depthStates ?? 0} states, ${s.depthPairs ?? 0} overlapping pairs`);
  for (const change of result.changes ?? []) lines.push(`  ${change.family} at ${f3(change.x)}: ${change.count} pair${change.count === 1 ? "" : "s"} reorder (${change.pairs.join("; ")})`);
  lines.push(`turn audit · ${f2(s.seconds ?? 0)} s`);
  return lines.join("\n");
}

export const orbitFailures = (result) => ["separation", "unseparated", "cycles", "forced", "swaps", "layers", "rigid", "swept", "posed", "folds", "self", "crowding", "endon", "coverage", "depth", "continuity"].reduce((sum, key) => sum + result[key].length, 0);

export function orbitOrExit(T, R, P, { flag = "--audit", argv = process.argv, ...options } = {}) {
  if (!argv.includes(flag)) return null;
  const result = orbit(T, R, P, options);
  console.log(orbitReport(result));
  const count = orbitFailures(result);
  const skipped = result.skipped.length;
  const partial = skipped ? `${skipped} line${skipped === 1 ? "" : "s"} skipped by only` : "";
  const undersampled = result.quick ? "quick run, undersampled: the full run is the proof" : "";
  const notes = [partial, undersampled].filter(Boolean).join("; ");
  if (count) console.log(`turn audit failed: ${count} problem${count === 1 ? "" : "s"}${notes ? ` (${notes})` : ""}`);
  else if (notes) console.log(`turn audit ${skipped ? "partial" : "quick"}: no problems in what ran (${notes})`);
  else console.log("turn audit passed");
  if (count) process.exitCode = 1;
  return result;
}
```

## kit/turn-build.mjs

```js
import { TURN, TURN_CSS } from "./turn.mjs";
import * as k from "./iso-kit.mjs";
import * as G from "./lathe.mjs";
import * as A from "./audit.mjs";

const TAU = Math.PI * 2;
const r4 = (v) => Math.round(v * 1e4) / 1e4;
const r3 = (v) => Math.round(v * 1e3) / 1e3;
const pack3 = (value) => JSON.parse(JSON.stringify(value, (key, v) => (typeof v === "number" ? r3(v) : v instanceof Float64Array || v instanceof Float32Array ? Array.from(v, r3) : v)));
const pack = (value) => JSON.parse(JSON.stringify(value, (key, v) => (typeof v === "number" ? r4(v) : v instanceof Float64Array || v instanceof Float32Array ? Array.from(v, r4) : v)));
const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

function octOf(n) {
  const l = Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2]) || 1;
  let x = n[0] / l;
  let y = n[1] / l;
  if (n[2] < 0) {
    const ox = (1 - Math.abs(y)) * (x >= 0 ? 1 : -1);
    const oy = (1 - Math.abs(x)) * (y >= 0 ? 1 : -1);
    x = ox;
    y = oy;
  }
  return [Math.round((x * 0.5 + 0.5) * 65535), Math.round((y * 0.5 + 0.5) * 65535)];
}

function packPairs(byLayer, stride, grouped) {
  const out = {};
  for (const [name, list] of Object.entries(byLayer)) {
    const count = list.length / stride;
    const bytes = new Uint8Array(count * (grouped ? 10 : 8));
    const view = new DataView(bytes.buffer);
    for (let e = 0; e < count; e++) {
      const at = e * stride;
      let o = e * (grouped ? 10 : 8);
      view.setUint16(o, list[at]);
      view.setUint16(o + 2, list[at + 1]);
      o += 4;
      if (grouped) {
        view.setUint16(o, list[at + 2] + 1);
        o += 2;
      }
      const [u, v] = octOf(list.slice(at + stride - 3, at + stride));
      view.setUint16(o, u);
      view.setUint16(o + 2, v);
    }
    out[name] = Buffer.from(bytes).toString("base64");
  }
  return out;
}

function signedArea(points) {
  let s = 0;
  for (let i = 0; i < points.length; i++) {
    const a = points[i];
    const b = points[(i + 1) % points.length];
    s += a[0] * b[1] - b[0] * a[1];
  }
  return s / 2;
}

function convexOrThrow(points, name) {
  const n = points.length;
  let sign = 0;
  for (let i = 0; i < n; i++) {
    const a = points[i];
    const b = points[(i + 1) % n];
    const c = points[(i + 2) % n];
    const cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
    if (Math.abs(cross) < 1e-9) continue;
    if (!sign) sign = Math.sign(cross);
    else if (Math.sign(cross) !== sign) throw new Error(`turning: part ${name} is not convex; split it into convex pieces`);
  }
}

export function roundedPolygon(polygon, radius = 0, steps = 4) {
  const poly = signedArea(polygon) < 0 ? polygon.slice().reverse() : polygon.slice();
  if (!(radius > 0)) return poly.map(([x, y]) => [x, y]);
  const n = poly.length;
  const normals = poly.map((a, i) => {
    const b = poly[(i + 1) % n];
    const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    return [(b[1] - a[1]) / l, -(b[0] - a[0]) / l];
  });
  const out = [];
  for (let i = 0; i < n; i++) {
    const na = normals[(i - 1 + n) % n];
    const nb = normals[i];
    const denom = 1 + na[0] * nb[0] + na[1] * nb[1];
    const c = [poly[i][0] - (radius * (na[0] + nb[0])) / denom, poly[i][1] - (radius * (na[1] + nb[1])) / denom];
    const a0 = Math.atan2(na[1], na[0]);
    let a1 = Math.atan2(nb[1], nb[0]);
    while (a1 < a0 - 1e-9) a1 += TAU;
    for (let s = 0; s <= steps; s++) {
      const t = a0 + ((a1 - a0) * s) / steps;
      out.push([c[0] + radius * Math.cos(t), c[1] + radius * Math.sin(t)]);
    }
  }
  return out;
}

const fine = (v) => {
  const t = Math.round(v * 1000) / 1000;
  return t === 0 ? "0" : String(t);
};
const finePath = (points) => points.map(([x, y], i) => `${i ? "L" : "M"}${fine(x)} ${fine(y)}`).join("") + "Z";

const dot3 = G.dot3;
const sub3 = G.sub3;
const add3 = G.add3;
const mul3 = G.mul3;

function marginOf(n, A, B) {
  let hiA = -Infinity;
  for (const a of A) {
    const v = n[0] * a[0] + n[1] * a[1] + n[2] * a[2];
    if (v > hiA) hiA = v;
  }
  let loB = Infinity;
  for (const b of B) {
    const v = n[0] * b[0] + n[1] * b[1] + n[2] * b[2];
    if (v < loB) loB = v;
  }
  return { margin: loB - hiA, d: (loB + hiA) / 2 };
}

function closest(A, B, rounds = 200) {
  let w = sub3(B[0], A[0]);
  for (let it = 0; it < rounds; it++) {
    const l = G.len3(w);
    if (l < 1e-12) break;
    let hi = -Infinity;
    let a = A[0];
    for (const p of A) {
      const v = dot3(p, w);
      if (v > hi) {
        hi = v;
        a = p;
      }
    }
    let lo = Infinity;
    let b = B[0];
    for (const p of B) {
      const v = dot3(p, w);
      if (v < lo) {
        lo = v;
        b = p;
      }
    }
    const s = sub3(b, a);
    const ds = sub3(w, s);
    const dd = dot3(ds, ds);
    if (dd < 1e-14) break;
    const t = Math.max(0, Math.min(1, dot3(w, ds) / dd));
    const next = sub3(w, mul3(ds, t));
    if (G.len3(sub3(next, w)) < 1e-10) break;
    w = next;
  }
  return w;
}

export function gjk(A, B, warm = null) {
  const w = warm && G.len3(warm) > 1e-9 ? warm : closest(A, B);
  const n = G.unit3(G.len3(w) > 1e-12 ? w : [0, 0, 1]);
  const { margin, d } = marginOf(n, A, B);
  return { n, d, margin };
}

function quickSeparate(A, B, hints = []) {
  const centre = (list) => mul3(list.reduce((sum, p) => add3(sum, p), [0, 0, 0]), 1 / list.length);
  const candidates = [sub3(centre(B), centre(A)), ...hints.flatMap((h) => [h, mul3(h, -1)])].filter((n) => G.len3(n) > 1e-9).map(G.unit3);
  let best = null;
  for (const n of candidates) {
    const { margin } = marginOf(n, A, B);
    if (!best || margin > best.margin) best = { n, margin };
  }
  if (best.margin >= 0.1) return best;
  let { n, margin } = best;
  for (const step of [0.3, 0.1, 0.03]) {
    for (let round = 0; round < 12; round++) {
      const F = G.frameAlong([0, 0, 0], n);
      let improved = false;
      for (let j = 0; j < 8; j++) {
        const t = (j / 8) * TAU;
        const trial = G.unit3(add3(n, add3(mul3(F.u, step * Math.cos(t)), mul3(F.v, step * Math.sin(t)))));
        const value = marginOf(trial, A, B).margin;
        if (value > margin + 1e-9) {
          margin = value;
          n = trial;
          improved = true;
        }
      }
      if (!improved) break;
    }
  }
  return { n, margin };
}

export function separate(A, B, hints = []) {
  const centre = (list) => mul3(list.reduce((s, p) => add3(s, p), [0, 0, 0]), 1 / list.length);
  const candidates = [sub3(centre(B), centre(A)), closest(A, B), ...hints.flatMap((h) => [h, mul3(h, -1)])].filter((n) => G.len3(n) > 1e-9).map(G.unit3);
  let best = null;
  for (const n of candidates) {
    const { margin } = marginOf(n, A, B);
    if (!best || margin > best.margin) best = { n, margin };
  }
  let { n, margin } = best;
  for (const step of [0.3, 0.1, 0.03, 0.01, 0.003, 0.001]) {
    for (let round = 0; round < 40; round++) {
      const F = G.frameAlong([0, 0, 0], n);
      let improved = false;
      for (let j = 0; j < 8; j++) {
        const t = (j / 8) * TAU;
        const trial = G.unit3(add3(n, add3(mul3(F.u, step * Math.cos(t)), mul3(F.v, step * Math.sin(t)))));
        const value = marginOf(trial, A, B).margin;
        if (value > margin + 1e-9) {
          margin = value;
          n = trial;
          improved = true;
        }
      }
      if (!improved) break;
    }
  }
  return { n, d: marginOf(n, A, B).d, margin };
}

function frameFrom(F) {
  const a = G.unit3(F.a);
  let u = F.u ? G.unit3(sub3(F.u, mul3(a, dot3(F.u, a)))) : null;
  if (!u || G.len3(u) < 1e-9) u = G.frameAlong([0, 0, 0], a).u;
  const v = F.v && Math.abs(dot3(G.cross3(a, u), F.v)) > 0.999 ? G.unit3(F.v) : G.cross3(a, u);
  return { o: F.o.slice(), a, u, v };
}

function sampleShape(shape, count = 48) {
  const out = [];
  if (shape.kind === "body") {
    const F = frameFrom(shape.F);
    for (const [s, r] of shape.poly) {
      if (r < 1e-9) {
        out.push(G.pointOf(F, s, 0, 0));
        continue;
      }
      for (let j = 0; j < count; j++) out.push(G.pointOf(F, s, r, (j / count) * TAU));
    }
  } else if (shape.kind === "prism") {
    const F = shape.F;
    for (const s of [shape.s0, shape.s1]) for (const [x, y] of shape.poly) out.push(add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y))));
  } else if (shape.kind === "ball") {
    const golden = Math.PI * (3 - Math.sqrt(5));
    const total = Math.max(count * 3, 96);
    for (let i = 0; i < total; i++) {
      const y = 1 - (2 * (i + 0.5)) / total;
      const rad = Math.sqrt(1 - y * y);
      const t = golden * i;
      const n = [Math.cos(t) * rad, Math.sin(t) * rad, y];
      if ((shape.flats ?? []).some(([nx, ny, nz, d]) => nx * n[0] * shape.r + ny * n[1] * shape.r + nz * n[2] * shape.r > d + 1e-9)) continue;
      out.push(add3(shape.o, mul3(n, shape.r)));
    }
    for (const [nx, ny, nz, d] of shape.flats ?? []) {
      const F = G.frameAlong(add3(shape.o, mul3([nx, ny, nz], d)), [nx, ny, nz]);
      const rho = Math.sqrt(Math.max(0, shape.r * shape.r - d * d));
      for (let j = 0; j < count; j++) {
        const p = G.pointOf(F, 0, rho, (j / count) * TAU);
        if ((shape.flats ?? []).every(([mx, my, mz, e]) => mx * (p[0] - shape.o[0]) + my * (p[1] - shape.o[1]) + mz * (p[2] - shape.o[2]) <= e + 1e-6)) out.push(p);
      }
    }
  } else if (shape.kind === "box") {
    for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) out.push(add3(shape.o, add3(add3(mul3(shape.axes[0], sx * shape.half[0]), mul3(shape.axes[1], sy * shape.half[1])), mul3(shape.axes[2], sz * shape.half[2]))));
  } else if (shape.kind === "tube") {
    const pts = shape.points;
    for (let i = 0; i < pts.length; i++) {
      const t = G.unit3(sub3(pts[Math.min(pts.length - 1, i + 1)], pts[Math.max(0, i - 1)]));
      const F = G.frameAlong(pts[i], t);
      const ring = Math.max(8, Math.round(count / 4));
      for (let j = 0; j < ring; j++) out.push(G.pointOf(F, 0, shape.radii[i], (j / ring) * TAU));
    }
  }
  return out;
}

function hull2(points) {
  return G.hullOf(points);
}

function thinCloud(points, most = 64) {
  if (points.length <= most) return points;
  const out = [];
  const step = points.length / most;
  for (let i = 0; i < most; i++) out.push(points[Math.floor(i * step)]);
  return out;
}

export function turning(P, { recorder = null, scale = 1 } = {}) {
  const T = { P, R: recorder, scale, groups: [], layers: [], order: [], parts: [], built: null };
  const groupOf = (g) => (g === undefined || g === null ? null : typeof g === "string" ? T.groups.find((x) => x.name === g) : g);
  const layerOf = (l) => (typeof l === "string" ? T.layers.find((x) => x.name === l) : l);
  const origin = (group) => (group ? group.origin : [0, 0, 0]);
  const local = (group, p) => sub3(p, origin(group));
  T.group = (name, options = {}) => {
    const parent = groupOf(options.parent);
    const po = origin(parent);
    let kind;
    let spec;
    if (options.turn) {
      kind = "turn";
      const pivot = options.turn.pivot ?? [0, 0];
      spec = { origin: [pivot[0], pivot[1], 0], pivot: [pivot[0], pivot[1]], range: options.turn.range ?? null };
    } else if (options.slide) {
      kind = "slide";
      spec = { origin: po.slice(), direction: G.unit3(options.slide.direction), travel: options.slide.travel ?? 0, range: [0, options.slide.travel ?? 0] };
    } else if (options.hinge) {
      kind = "hinge";
      spec = { origin: options.hinge.point.slice(), point: options.hinge.point.slice(), axis: G.unit3(options.hinge.axis), range: options.hinge.range ?? [0, 0] };
    } else if (options.free) {
      kind = "free";
      const at = options.free.origin ?? po;
      spec = { origin: at.slice(), range: null };
    } else throw new Error(`turning: group ${name} needs turn, slide, hinge or free`);
    const upright = kind !== "free" && (parent ? parent.upright : true) && (kind !== "hinge" || Math.abs(spec.axis[2]) > 0.9999);
    const loose = kind === "free" || Boolean(parent && parent.loose);
    const group = { name, index: T.groups.length, parent, kind, upright, loose, ...spec };
    T.groups.push(group);
    return group;
  };
  T.layer = (name) => {
    const layer = { name, index: T.layers.length, live: true };
    T.layers.push(layer);
    return layer;
  };
  T.stack = (names) => {
    T.order = names.slice();
    return T;
  };
  const common = (options, kind) => {
    const group = groupOf(options.group);
    const layer = layerOf(options.layer ?? T.layers[0]);
    if (!layer) throw new Error(`turning: part ${options.name} has no live layer`);
    const name = options.name ?? `${kind}#${T.parts.length}`;
    const material = options.material ?? null;
    if (material && !TURN.materials.includes(material)) throw new Error(`turning: part ${name} has material ${material}; use one of ${TURN.materials.join(", ")}`);
    return { id: T.parts.length, name, kind, group, layer, owner: options.owner, tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: Boolean(options.lit), material, details: options.details ?? {}, shapes: [], planes: [], dots: [], samples: [], hints: [] };
  };
  const record = (part, shape) => {
    shape.name = shape.name ?? part.name;
    if (part.owner && !shape.owner) shape.owner = part.owner;
    part.shapes.push(shape);
    if (T.R) T.R.solid(shape);
    return shape;
  };
  const toLocalDots = (part, F, list) => {
    for (const dot of list ?? []) {
      const [s, x, y] = dot.at;
      const p = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
      part.dots.push({ p: local(part.group, p), n: dot.normal ? G.unit3(dot.normal) : null, size: dot.size ?? 0.5, tone: dot.tone ?? "mid", fade: dot.fade ?? [0.04, 0.42] });
    }
  };
  T.prism = (options) => {
    const part = common(options, "prism");
    let F;
    let ring;
    let s0;
    let s1;
    if (options.plan) {
      F = { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] };
      ring = k.roundedPlan(options.plan, options.steps ?? 4);
      s0 = options.z ?? options.s0 ?? 0;
      s1 = options.h !== undefined ? s0 + options.h : options.s1;
    } else {
      F = frameFrom(options.F);
      ring = roundedPolygon(options.polygon, options.round ?? 0, options.steps ?? 4);
      s0 = options.s0;
      s1 = options.s1;
    }
    if (s1 < s0) [s0, s1] = [s1, s0];
    if (signedArea(ring) < 0) ring = ring.slice().reverse();
    convexOrThrow(ring, part.name);
    const bevel = options.bevel ?? part.details.bevel ?? 0;
    const upright = Math.abs(F.a[2]) > 0.9999 && (!part.group || part.group.upright);
    record(part, A.prism(F, ring, s0, s1, { name: part.name }));
    const corner = (s, [x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
    part.world = { F, ring, s0, s1 };
    if (upright) {
      const flip = F.a[2] < 0;
      const pts = ring.map((p) => local(part.group, corner(flip ? s1 : s0, p)));
      let xy = pts.map(([x, y]) => [x, y]);
      if (signedArea(xy) < 0) xy = xy.reverse();
      const z0 = pts[0][2];
      const z1 = z0 + (s1 - s0);
      part.spec = { up: 1, ring: xy.flat(), z0, z1, bevel };
      part.planPath = finePath(xy);
      part.localSeams = (part.details.seams ?? []).map((s) => (flip ? z1 - (s - s0) : z0 + (s - s0)));
    } else {
      const FL = { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v };
      part.spec = { up: 0, F: FL, ring: ring.flat(), s0, s1, bevel };
      part.localSeams = (part.details.seams ?? []).slice();
    }
    toLocalDots(part, F, part.details.dots);
    for (const s of [s0, s1]) for (const p of ring) part.samples.push(local(part.group, corner(s, p)));
    part.hints.push(F.a);
    const n = ring.length;
    for (let i = 0; i < n; i++) {
      const a = ring[i];
      const b = ring[(i + 1) % n];
      const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
      if (l > 1e-6) part.hints.push(G.unit3(add3(mul3(F.u, (b[1] - a[1]) / l), mul3(F.v, -(b[0] - a[0]) / l))));
    }
    T.parts.push(part);
    return part;
  };
  const detailParts = (part, F, radiusAt, ends) => {
    const details = part.details ?? {};
    for (const bolt of details.bolts ?? []) {
      const { s, r, count, phase = 0, size = 0.5, tone = "mid", fade = [0.04, 0.42] } = bolt;
      const atEnd = ends ? (Math.abs(s - ends[0]) < 1e-6 ? -1 : Math.abs(s - ends[1]) < 1e-6 ? 1 : 0) : 0;
      const onCap = atEnd !== 0 && (!radiusAt || r < radiusAt(s) - 1e-6);
      const slope = radiusAt ? (radiusAt(s + 1e-3) - radiusAt(s - 1e-3)) / 2e-3 : 0;
      for (let j = 0; j < count; j++) {
        const t = phase + (j / count) * TAU;
        const p = G.pointOf(F, s, r, t);
        const radial = G.radialOf(F, t);
        const n = onCap ? mul3(F.a, atEnd) : G.unit3(sub3(radial, mul3(F.a, slope)));
        part.dots.push({ p: local(part.group, p), n, size, tone, fade });
      }
    }
    toLocalDots(part, F, details.dots);
    for (const ring of details.rings ?? []) {
      const [s, x, y] = ring.at;
      const o = add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
      const n = G.unit3(ring.normal ?? F.a);
      const B = G.frameAlong([0, 0, 0], n);
      const steps = Math.max(12, Math.ceil((TAU * ring.r * k.cameraOf(P).k) / 1.2));
      const pts = Array.from({ length: steps }, (_, j) => [ring.r * Math.cos((j / steps) * TAU), ring.r * Math.sin((j / steps) * TAU)]);
      T.plane({ part, o, u: B.u, v: B.v, normal: n, fade: ring.fade ?? [0.04, 0.3], svg: `<path class="iso-line" data-tone="${ring.tone ?? "lo"}" d="${finePath(pts)}"/>` });
    }
    for (const rule of details.rules ?? []) {
      const pts = rule.points.map(([s, x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y))));
      let n = rule.normal ? G.unit3(rule.normal) : null;
      if (!n) {
        for (let i = 2; i < pts.length && !n; i++) {
          const c = G.cross3(sub3(pts[1], pts[0]), sub3(pts[i], pts[0]));
          if (G.len3(c) > 1e-9) n = G.unit3(c);
        }
        n = n ?? F.a;
      }
      const B = G.frameAlong([0, 0, 0], n);
      const o = pts[0];
      const flat = pts.map((p) => [dot3(sub3(p, o), B.u), dot3(sub3(p, o), B.v)]);
      const d = flat.map(([x, y], i) => `${i ? "L" : "M"}${fine(x)} ${fine(y)}`).join("");
      const free = rule.free === true ? ' data-free=""' : rule.free ? ` data-free="${rule.free}"` : "";
      T.plane({ part, o, u: B.u, v: B.v, normal: n, fade: rule.fade ?? [0.04, 0.3], svg: `<path class="iso-line" data-tone="${rule.tone ?? "lo"}"${free} d="${d}"/>` });
    }
  };
  T.round = (options) => {
    const F = frameFrom(options.F);
    const r0 = Array.isArray(options.r) ? options.r[0] : options.r0 ?? options.r;
    const r1 = Array.isArray(options.r) ? options.r[1] : options.r1 ?? options.r;
    let s0 = options.s0;
    let s1 = options.s1;
    const ends = (options.ends ?? ["flat", "flat"]).slice();
    if (s1 < s0) throw new Error(`turning: round ${options.name} needs s0 < s1`);
    ends.forEach((end, j) => {
      if (end === "dome" && Math.abs(r0 - r1) > 1e-9) throw new Error(`turning: round ${options.name} can only have a dome where the end radius equals the side's (a capsule)`);
    });
    const profile = [];
    const arc = (sAt, sign, r) => Array.from({ length: 9 }, (_, j) => (j / 8) * (Math.PI / 2)).map((b) => [sAt + sign * r * Math.cos(b), r * Math.sin(b), 1]);
    if (ends[0] === "dome") profile.push(...arc(s0, -1, r0).slice(0, -1).map((p, j) => (j === 0 ? [p[0], 0] : p)), [s0, r0, 1]);
    else profile.push([s0, r0]);
    if (ends[1] === "dome") profile.push([s1, r1, 1], ...arc(s1, 1, r1).reverse().slice(1).map((p, j, list) => (j === list.length - 1 ? [p[0], 0] : p)));
    else profile.push([s1, r1]);
    const vertical = Math.abs(F.a[2]) > 0.9999;
    const groupOk = !options.group || groupOf(options.group).upright;
    const radiusAt = (s) => r0 + ((r1 - r0) * (Math.max(s0, Math.min(s1, s)) - s0)) / (s1 - s0 || 1);
    if (vertical && groupOk && options.fixed !== false) {
      const meridian = [[profile[0][0], 0], ...profile.filter((p) => p[1] > 1e-9), [profile[profile.length - 1][0], 0]];
      const seams = (options.details?.seams ?? []).map((sv) => G.arcOf(F, sv, radiusAt(sv), P, { slope: (r1 - r0) / (s1 - s0 || 1), least: 0 })).join("");
      const paths = G.lathe(meridian, F, P, ends.includes("dome") ? { smooth: true } : {});
      const svg = k.solidSvg(paths, { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit, inner: seams ? k.lineSvg(seams, { tone: "faint" }) : "" });
      const ribs = options.details?.ribs ? { ...options.details.ribs, r0: options.details.ribs.r0 ?? radiusAt(options.details.ribs.s0), r1: options.details.ribs.r1 ?? radiusAt(options.details.ribs.s1) } : undefined;
      const part = T.fixed({ ...options, svg, anchor: G.pointOf(F, s0, 0, 0), F, details: { ...(options.details ?? {}), ribs, seams: undefined }, shapes: [A.solid(F, profile, { name: options.name })], skipDetails: true });
      part.round = { F, s0, s1, r0, r1, ends };
      detailParts(part, F, radiusAt, [s0, s1]);
      return part;
    }
    const part = common(options, "round");
    record(part, A.solid(F, profile, { name: part.name }));
    part.world = { F, s0, s1, r0, r1, ends, profile };
    part.spec = { F: { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v }, s0, s1, r0, r1, ends };
    detailParts(part, F, radiusAt, [s0, s1]);
    const ring = (s, r, count = 48) => Array.from({ length: count }, (_, j) => G.pointOf(F, s, r, (j / count) * TAU));
    const pts = [...ring(s0, r0), ...ring(s1, r1)];
    for (const [end, sAt, r] of [[0, s0, r0], [1, s1, r1]]) {
      if (ends[end] !== "dome") continue;
      const sign = end ? 1 : -1;
      for (let j = 1; j <= 6; j++) {
        const b = (j / 6) * (Math.PI / 2);
        if (j === 6) pts.push(add3(F.o, mul3(F.a, sAt + sign * r)));
        else pts.push(...ring(sAt + sign * r * Math.sin(b), r * Math.cos(b), 32));
      }
    }
    part.samples = pts.map((p) => local(part.group, p));
    part.hints.push(F.a);
    T.parts.push(part);
    return part;
  };
  T.ball = (options) => {
    const c = options.c;
    const r = options.r;
    const flats = (options.flats ?? []).map(({ n, d }) => ({ n: G.unit3(n), d }));
    const shape = A.ball(c, r, { name: options.name, flats: flats.map(({ n, d }) => [n[0], n[1], n[2], d]) });
    if (!flats.length && options.fixed !== false) {
      const svg = k.solidSvg(G.sphereOf(c, r, P, { steps: 48 }), { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit });
      return T.fixed({ ...options, svg, anchor: c, shapes: [shape] });
    }
    const part = common(options, "ball");
    record(part, shape);
    part.world = { c, r, flats };
    part.spec = { c: local(part.group, c), r, flats };
    part.samples = sampleShape(shape, 48).map((p) => local(part.group, p));
    for (const { n } of flats) part.hints.push(n);
    detailParts(part, G.frameAlong(c, flats.length ? flats[0].n : [0, 0, 1]), null, null);
    T.parts.push(part);
    return part;
  };
  T.lathe = (options) => {
    const F = frameFrom(options.F);
    const profile = options.profile;
    const curve = G.profileCurve(profile.map(([sv, rv]) => [sv, rv]), { slope: options.slope ?? null, radius: options.radius ?? null });
    const kk = k.cameraOf(P).k;
    const knots = [curve.s0];
    const split = (a, b, level) => {
      const mid = (a + b) / 2;
      if (level < 7 && (Math.abs(curve.radius(mid) - (curve.radius(a) + curve.radius(b)) / 2) * kk > 0.1 || b - a > 4)) {
        split(a, mid, level + 1);
        knots.push(mid);
        split(mid, b, level + 1);
      }
    };
    const stops = [curve.s0, ...profile.map(([sv]) => sv).filter((sv) => sv > curve.s0 && sv < curve.s1), curve.s1];
    for (let i = 0; i < stops.length - 1; i++) {
      split(stops[i], stops[i + 1], 0);
      knots.push(stops[i + 1]);
    }
    for (let i = 1; i < knots.length - 1; i++) {
      const a = curve.radius(knots[i - 1]);
      const b = curve.radius(knots[i]);
      const c = curve.radius(knots[i + 1]);
      const h0 = knots[i] - knots[i - 1];
      const h1 = knots[i + 1] - knots[i];
      if ((c - b) / h1 - (b - a) / h0 > 1e-6) throw new Error(`turning: lathe ${options.name} is not convex near s = ${knots[i].toFixed(2)} (r(s) must be concave); split it`);
    }
    const levels = [];
    for (const sv of knots) {
      const g = curve.slope(sv);
      const norm = Math.hypot(1, g);
      levels.push(sv, curve.radius(sv), 1 / norm, -g / norm);
    }
    const caps = [curve.radius(curve.s0) > 1e-6, curve.radius(curve.s1) > 1e-6];
    const shape = A.solid(F, profile.map(([sv, rv]) => [sv, rv, 1]), { name: options.name, radius: curve.radius });
    const vertical = Math.abs(F.a[2]) > 0.9999;
    const groupOk = !options.group || groupOf(options.group).upright;
    if (vertical && groupOk && options.fixed !== false) {
      const meridian = [[curve.s0, 0], ...knots.map((sv) => [sv, curve.radius(sv), 1]), [curve.s1, 0]];
      meridian[1] = [meridian[1][0], meridian[1][1]];
      meridian[meridian.length - 2] = [meridian[meridian.length - 2][0], meridian[meridian.length - 2][1]];
      const seams = (options.details?.seams ?? []).map((sv) => G.arcOf(F, sv, curve.radius(sv), P, { slope: curve.slope(sv), least: 0 })).join("");
      const svg = k.solidSvg(G.lathe(meridian, F, P, { smooth: { slope: curve.slope, radius: curve.radius } }), { tone: options.tone ?? "hi", crease: options.crease ?? "faint", lit: options.lit, inner: seams ? k.lineSvg(seams, { tone: "faint" }) : "" });
      const part = T.fixed({ ...options, svg, anchor: G.pointOf(F, curve.s0, 0, 0), F, details: { ...(options.details ?? {}), seams: undefined }, shapes: [shape], skipDetails: true });
      detailParts(part, F, curve.radius, [curve.s0, curve.s1]);
      return part;
    }
    const part = common(options, "lathe");
    record(part, shape);
    part.spec = { F: { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v }, levels, caps };
    part.world = { F, profile, curve, knots };
    const pts = [];
    for (const sv of knots) for (let j = 0; j < 32; j++) pts.push(G.pointOf(F, sv, curve.radius(sv), (j / 32) * TAU));
    part.samples = pts.map((p) => local(part.group, p));
    part.hints.push(F.a);
    detailParts(part, F, curve.radius, [curve.s0, curve.s1]);
    T.parts.push(part);
    return part;
  };
  T.routes = [];
  T.tube = (options) => {
    const { route, r, chunk = 6, turn = 20, gaps = [], breaks = [], caps = true, rings = null, hue = null, spacing = 0.8, owner, touch, bundle, rims, free } = options;
    const group = groupOf(options.group);
    const name = options.name ?? `tube#${T.routes.length}`;
    const dense = G.resample(route, spacing);
    const total = G.pathLength(dense);
    const turnBreaks = [];
    let walked = 0;
    let bent = 0;
    for (let i = 1; i < dense.length - 1; i++) {
      walked += G.len3(sub3(dense[i], dense[i - 1]));
      const a = G.unit3(sub3(dense[i], dense[i - 1]));
      const b = G.unit3(sub3(dense[i + 1], dense[i]));
      bent += (Math.acos(Math.max(-1, Math.min(1, dot3(a, b)))) * 180) / Math.PI;
      if (bent > turn) {
        turnBreaks.push(walked);
        bent = 0;
      }
    }
    const allBreaks = [...breaks, ...turnBreaks].sort((x, y) => x - y);
    const pieces = spansOf(total, gaps, allBreaks, chunk);
    const wide = r * k.cameraOf(P).k > 1.3;
    const routeIndex = T.routes.length;
    const capList = [caps].flat();
    T.routes.push({ name, group, points: dense.map((p) => local(group, p)), r, spacing, wide, hue, rings, world: route, gaps, breaks: allBreaks, chunk, caps });
    if (T.R) T.R.route(name, route, r, { gaps, limp: options.limp ?? true, owner: owner ?? name, touch, bundle, rims, free });
    const chunks = pieces.map((piece, index) => {
      const part = common({ ...options, name: `${name}:${index}` }, "tube");
      part.route = name;
      part.chunk = index;
      part.hue = hue;
      part.spec = { route: routeIndex, from: piece.from, to: piece.to, open0: piece.open0, open1: piece.open1, gap0: piece.gap0, gap1: piece.gap1, caps: [capList[0], capList[capList.length - 1]], wide, rings: rings ? { tone: rings.tone ?? "lo" } : null };
      const core = G.cut(dense, piece.from, piece.to);
      part.shapes.push(A.tube(core, r, { name: part.name, owner: owner ?? name }));
      part.world = { route, r, index };
      for (let i = 0; i < core.length; i++) {
        const t = G.unit3(sub3(core[Math.min(core.length - 1, i + 1)], core[Math.max(0, i - 1)]));
        const F = G.frameAlong(core[i], t);
        for (let j = 0; j < 10; j++) part.samples.push(local(group, G.pointOf(F, 0, r, (j / 10) * TAU)));
      }
      part.hints.push(G.unit3(sub3(core[1], core[0])), G.unit3(sub3(core[core.length - 1], core[core.length - 2])));
      T.parts.push(part);
      return part;
    });
    return { name, chunks, total, dense };
  };
  T.fixed = (options) => {
    const part = common(options, "fixed");
    const anchor = options.anchor;
    for (const shape of options.shapes ?? []) record(part, shape);
    const world = part.shapes.flatMap((shape) => sampleShape(shape, 48));
    const flat = world.map((p) => k.iso(p, P));
    const hull = options.hull ?? hull2(flat);
    const box = A.markupBox(options.svg) ?? [Math.min(...hull.map((p) => p[0])), Math.min(...hull.map((p) => p[1])), Math.max(...hull.map((p) => p[0])), Math.max(...hull.map((p) => p[1]))];
    part.svg = options.svg;
    const F = options.F ? frameFrom(options.F) : null;
    part.spec = { anchor: local(part.group, anchor), home: k.iso(anchor, P), box: [box[0] - 0.5, box[1] - 0.5, box[2] + 0.5, box[3] + 0.5], hull: hull.flat(), F: F ? { o: local(part.group, F.o), a: F.a, u: F.u, v: F.v } : null };
    if (F && !options.skipDetails) detailParts(part, F, null, null);
    part.samples = world.map((p) => local(part.group, p));
    if (F) part.hints.push(F.a);
    T.parts.push(part);
    return part;
  };
  T.item = (options) => {
    const part = common(options, "item");
    const cloud = options.cloud ?? [[0, 0, 0]];
    part.spec = { item: 1 };
    part.cloud = cloud.map((p) => local(part.group, p)).flat();
    part.samples = cloud.map((p) => local(part.group, p));
    T.parts.push(part);
    return part;
  };
  T.plane = ({ part, o, u, v, svg, fade = [0.04, 0.3], normal = null }) => {
    const U = G.unit3(u);
    const W = G.unit3(v);
    const n = normal ? G.unit3(normal) : G.unit3(G.cross3(U, W));
    part.planes.push({ o: local(part.group, o), u: U, v: W, n, fade, svg });
    return part;
  };
  T.billboard = ({ part, at, normal, svg, fade = [0.04, 0.3] }) => {
    part.planes.push({ o: local(part.group, at), u: [1, 0, 0], v: [0, 1, 0], n: G.unit3(normal), fade, svg, billboard: true });
    return part;
  };
  const drop = (part) => {
    T.parts.splice(T.parts.indexOf(part), 1);
    T.parts.forEach((p, index) => (p.id = index));
  };
  T.split = (part, { n, d }) => {
    const N = G.unit3(n);
    if (part.kind === "prism") {
      const { F, ring, s0, s1 } = part.world;
      const along = dot3(N, F.a);
      const base = { name: part.name, group: part.group, layer: part.layer, tone: part.tone, crease: part.crease, lit: part.lit, material: part.material, details: { ...part.details }, bevel: part.spec.bevel };
      let halves;
      if (Math.abs(Math.abs(along) - 1) < 1e-6) {
        const sCut = (d - dot3(N, F.o)) / along;
        if (!(sCut > s0 + 1e-6 && sCut < s1 - 1e-6)) throw new Error(`turning: split plane misses ${part.name}`);
        halves = [{ s0, s1: sCut }, { s0: sCut, s1 }].map((span) => ({ ...base, F, polygon: ring, s0: span.s0, s1: span.s1 }));
      } else if (Math.abs(along) < 1e-6) {
        const nu = dot3(N, F.u);
        const nv = dot3(N, F.v);
        const c = d - dot3(N, F.o);
        const side = (p) => nu * p[0] + nv * p[1] - c;
        const clip = (sign) => {
          const out = [];
          for (let i = 0; i < ring.length; i++) {
            const a = ring[i];
            const b = ring[(i + 1) % ring.length];
            const sa = sign * side(a);
            const sb = sign * side(b);
            if (sa <= 0) out.push(a);
            if ((sa < 0 && sb > 0) || (sa > 0 && sb < 0)) {
              const t = sa / (sa - sb);
              out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
            }
          }
          return out;
        };
        halves = [clip(1), clip(-1)].map((polygon) => ({ ...base, F, polygon, s0, s1 }));
        if (halves.some((h) => h.polygon.length < 3)) throw new Error(`turning: split plane misses ${part.name}`);
      } else throw new Error(`turning: a prism can only be split by a plane along or across its axis (${part.name})`);
      part.dead = true;
      if (T.R) T.R.solids.splice(T.R.solids.indexOf(part.shapes[0]), 1);
      const made = halves.map((h, index) => T.prism({ ...h, name: `${part.name}.${index ? "b" : "a"}`, round: 0, details: index ? { ...h.details, dots: [] } : h.details }));
      for (const half of made) {
        half.spec.cuts = [...(part.spec.cuts ?? []), { n: N, d: d - dot3(N, half.group ? half.group.origin : [0, 0, 0]) }];
        half.hints.unshift(N);
      }
      drop(part);
      return made;
    }
    if (part.kind === "round") {
      const { F, s0, s1, r0, r1, ends } = part.world;
      const along = dot3(N, F.a);
      if (Math.abs(Math.abs(along) - 1) > 1e-6) throw new Error(`turning: a round can only be split across its axis (${part.name})`);
      const sCut = (d - dot3(N, F.o)) / along;
      if (!(sCut > s0 + 1e-6 && sCut < s1 - 1e-6)) throw new Error(`turning: split plane misses ${part.name}`);
      const rAt = r0 + ((r1 - r0) * (sCut - s0)) / (s1 - s0);
      part.dead = true;
      if (T.R) T.R.solids.splice(T.R.solids.indexOf(part.shapes[0]), 1);
      const base = { group: part.group, layer: part.layer, tone: part.tone, crease: part.crease, lit: part.lit, material: part.material, F, fixed: false };
      const made = [
        T.round({ ...base, name: `${part.name}.a`, s0, s1: sCut, r: [r0, rAt], ends: [ends[0], "open"], details: part.details }),
        T.round({ ...base, name: `${part.name}.b`, s0: sCut, s1, r: [rAt, r1], ends: ["open", ends[1]] }),
      ];
      for (const half of made) {
        half.spec.cuts = [...(part.spec.cuts ?? []), { n: N, d: d - dot3(N, half.group ? half.group.origin : [0, 0, 0]) }];
        half.hints.unshift(N);
      }
      drop(part);
      return made;
    }
    throw new Error(`turning: split supports prisms and rounds (${part.name})`);
  };
  T.build = (options = {}) => build(T, options);
  return T;
}

function spansOf(total, gaps, breaks, maxLength) {
  const cuts = gaps
    .map(([a, b]) => [Math.max(0, Math.min(a, b)), Math.min(total, Math.max(a, b))])
    .filter(([a, b]) => b - a > 1e-6)
    .sort((x, y) => x[0] - y[0]);
  const runs = [];
  let at = 0;
  for (const [a, b] of cuts) {
    if (a > at + 1e-6) runs.push([at, a, at > 0, true]);
    at = Math.max(at, b);
  }
  if (total > at + 1e-6) runs.push([at, total, at > 0, cuts.length > 0 && cuts[cuts.length - 1][1] >= total - 1e-6]);
  const pieces = [];
  for (const [from, to, gapStart, gapEnd] of runs) {
    const stops = [from, ...breaks.filter((b) => b > from + 1e-6 && b < to - 1e-6).sort((x, y) => x - y), to];
    for (let index = 0; index < stops.length - 1; index++) {
      const a = stops[index];
      const b = stops[index + 1];
      const count = Math.max(1, Math.ceil((b - a) / maxLength - 1e-9));
      for (let c = 0; c < count; c++) {
        pieces.push({
          from: a + ((b - a) * c) / count,
          to: a + ((b - a) * (c + 1)) / count,
          open0: !(index === 0 && c === 0),
          open1: !(index === stops.length - 2 && c === count - 1),
          gap0: index === 0 && c === 0 && gapStart,
          gap1: index === stops.length - 2 && c === count - 1 && gapEnd,
        });
      }
    }
  }
  return pieces;
}

function groupData(T) {
  return T.groups.map((g) => ({
    name: g.name,
    parent: g.parent ? g.parent.index : -1,
    kind: g.kind,
    origin: g.origin,
    pivot: g.pivot,
    direction: g.direction,
    travel: g.travel,
    point: g.point,
    axis: g.axis,
    range: g.range,
    upright: g.upright,
    loose: g.loose || undefined,
  }));
}

function partData(part) {
  const details = {};
  const seams = part.kind === "prism" ? part.localSeams : part.kind === "fixed" ? null : part.details.seams;
  if (seams && seams.length) details.seams = seams;
  if (part.details.ribs) details.ribs = part.details.ribs;
  const out = { name: part.name, kind: part.kind, group: part.group ? part.group.index : -1, layer: part.layer.index };
  if (part.tone !== "hi") out.tone = part.tone;
  if (part.crease !== "faint") out.crease = part.crease;
  if (part.lit) out.lit = true;
  if (part.material) out.mat = part.material;
  out.spec = part.spec;
  if (Object.keys(details).length) out.details = details;
  if (part.planes.length) out.planes = part.planes.map(({ o, u, v, n, fade, billboard }) => ({ o, u, v, n, fade, billboard }));
  if (part.dots.length) out.dots = part.dots.map(({ p, n, size, tone, fade }) => ({ p, n, size, tone, fade }));
  if (part.kind === "item") out.cloud = part.cloud;
  return out;
}

function statesOf(T, poses) {
  if (typeof poses === "function") poses = poses();
  if (Array.isArray(poses) && poses.length) return poses;
  const out = [];
  for (let deg = 0; deg < 360; deg += 2) out.push(Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "turn" ? deg : g.range ? g.range[0] + ((g.range[1] - g.range[0]) * (deg % 90)) / 90 : 0])));
  return out;
}

function tumbleStates(T, group, count = 160) {
  let root = group;
  while (root && root.kind !== "free") root = root.parent;
  if (!root) return [];
  let seed = 977;
  const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  const out = [];
  for (let i = 0; i < count; i++) {
    const u = rand();
    const a = rand() * TAU;
    const b = rand() * TAU;
    const q = [Math.sqrt(1 - u) * Math.sin(a), Math.sqrt(1 - u) * Math.cos(a), Math.sqrt(u) * Math.sin(b), Math.sqrt(u) * Math.cos(b)];
    const values = { [root.name]: { q } };
    for (const g of T.groups) if (g.kind === "hinge" && g.loose) values[g.name] = g.range[0] + (g.range[1] - g.range[0]) * rand();
    out.push(values);
  }
  return out;
}

function groupStates(T, group, states, { tumble = false } = {}) {
  if (!group) return [{}];
  let chain = [];
  for (let g = group; g; g = g.parent) chain.push(g);
  if (group.loose) return tumble ? [...states, ...tumbleStates(T, group)] : states.length ? states : [{}];
  if (chain.length === 1 && group.kind === "turn") return Array.from({ length: 360 }, (_, deg) => ({ [group.name]: deg }));
  if (chain.length === 1) {
    const [lo, hi] = group.range ?? [0, 0];
    return Array.from({ length: 33 }, (_, i) => ({ [group.name]: lo + ((hi - lo) * i) / 32 }));
  }
  return states;
}

function sweptGroups(T, layer, states, scene) {
  const V = G.viewOf(T.P);
  const cam = k.cameraOf(T.P);
  const byGroup = new Map();
  for (const part of T.parts) {
    if (part.layer !== layer || part.dead) continue;
    const key = part.group ? part.group.index : -1;
    if (!byGroup.has(key)) byGroup.set(key, []);
    byGroup.get(key).push(part);
  }
  const out = [];
  for (const [key, parts] of byGroup) {
    const group = key >= 0 ? T.groups[key] : null;
    const local = parts.flatMap((part) => part.samples);
    const root = group && group.kind === "turn" && !group.parent;
    const list = root ? Array.from({ length: 72 }, (_, i) => ({ [group.name]: i * 5 })) : group ? groupStates(T, group, states) : [{}];
    let zmin = Infinity;
    let zmax = -Infinity;
    const box = [Infinity, Infinity, -Infinity, -Infinity];
    const cloud = [];
    const stride = Math.max(1, Math.floor((local.length * list.length) / 4000));
    let counter = 0;
    for (const values of list) {
      const cams = TURN.cams(scene, values);
      const c = key >= 0 ? cams[key] : cams.world;
      for (const q of local) {
        const p = [c.R[0] * q[0] + c.R[1] * q[1] + c.R[2] * q[2] + c.t[0], c.R[3] * q[0] + c.R[4] * q[1] + c.R[5] * q[2] + c.t[1], c.R[6] * q[0] + c.R[7] * q[1] + c.R[8] * q[2] + c.t[2]];
        if (p[2] < zmin) zmin = p[2];
        if (p[2] > zmax) zmax = p[2];
        const [x, y] = k.iso(p, T.P);
        if (x < box[0]) box[0] = x;
        if (y < box[1]) box[1] = y;
        if (x > box[2]) box[2] = x;
        if (y > box[3]) box[3] = y;
        if (counter++ % stride === 0) cloud.push(p);
      }
    }
    let radius = 0;
    let envelope = cloud;
    if (root) {
      const slices = new Map();
      for (const q of local) {
        const band = Math.floor(q[2]);
        slices.set(band, Math.max(slices.get(band) ?? 0, Math.hypot(q[0], q[1])));
        radius = Math.max(radius, Math.hypot(q[0], q[1]));
      }
      envelope = [];
      for (const [band, r] of slices)
        for (const z of [band, band + 1])
          for (let j = 0; j < 48; j++) envelope.push([group.origin[0] + r * Math.cos((j / 48) * TAU), group.origin[1] + r * Math.sin((j / 48) * TAU), Math.max(zmin, Math.min(zmax, z))]);
      const pad = radius * cam.k;
      const c = k.iso([group.origin[0], group.origin[1], 0], T.P);
      box[0] = Math.min(box[0], c[0] - pad);
      box[2] = Math.max(box[2], c[0] + pad);
    }
    out.push({ key, group, parts, root, radius, zmin, zmax, box, envelope, V });
  }
  return out;
}

function relation(points, box, swept, P) {
  const V = G.viewOf(P);
  let zmin = Infinity;
  let zmax = -Infinity;
  for (const p of points) {
    zmin = Math.min(zmin, p[2]);
    zmax = Math.max(zmax, p[2]);
  }
  if (zmax <= swept.zmin + 1e-6) return { side: "behind", rule: "horizontal plane" };
  if (zmin >= swept.zmax - 1e-6) return { side: "front", rule: "horizontal plane" };
  if (swept.root) {
    const a = ((P.azimuth ?? 45) * Math.PI) / 180;
    const along = points.map((p) => (p[0] - swept.group.origin[0]) * Math.cos(a) + (p[1] - swept.group.origin[1]) * Math.sin(a));
    if (Math.max(...along) <= -swept.radius) return { side: "behind", rule: "cylinder" };
    if (Math.min(...along) >= swept.radius) return { side: "front", rule: "cylinder" };
  }
  if (box && (box[2] < swept.box[0] - 1 || swept.box[2] < box[0] - 1 || box[3] < swept.box[1] - 1 || swept.box[3] < box[1] - 1)) return { side: "apart", rule: "never overlap" };
  const hit = separate(points, swept.envelope, [[0, 0, 1], [Math.cos(((P.azimuth ?? 45) * Math.PI) / 180), Math.sin(((P.azimuth ?? 45) * Math.PI) / 180), 0]]);
  if (hit.margin >= -0.08) return { side: dot3(hit.n, V) > 0 ? "behind" : "front", rule: "swept envelope", margin: hit.margin };
  return null;
}

function proveLayers(T, statics, states, scene) {
  const order = T.order.length ? T.order : T.layers.map((layer) => layer.name);
  const rank = new Map(order.map((name, index) => [name, index]));
  const problems = [];
  const proofs = [];
  const swept = new Map(T.layers.map((layer) => [layer.name, sweptGroups(T, layer, states, scene)]));
  for (const layer of T.layers) if (!rank.has(layer.name)) problems.push(`live layer ${layer.name} is missing from T.stack`);
  for (const item of statics) {
    if (!item.shapes || !item.shapes.length) continue;
    const layerName = item.layer;
    if (!rank.has(layerName)) {
      problems.push(`static ${item.name} has layer ${layerName ?? "(none)"}, which is not in T.stack`);
      continue;
    }
    const points = item.shapes.flatMap((shape) => sampleShape(shape, 24));
    const flat = points.map((p) => k.iso(p, T.P));
    const box = [Math.min(...flat.map((p) => p[0])), Math.min(...flat.map((p) => p[1])), Math.max(...flat.map((p) => p[0])), Math.max(...flat.map((p) => p[1]))];
    for (const layer of T.layers) {
      const want = rank.get(layerName) < rank.get(layer.name) ? "behind" : "front";
      for (const group of swept.get(layer.name)) {
        const proof = relation(points, box, group, T.P);
        const label = `${item.name} × ${layer.name}${group.group ? `/${group.group.name}` : ""}`;
        if (!proof) {
          const zs = points.map((p) => p[2]);
          const top = Math.max(...zs);
          const bottom = Math.min(...zs);
          const where = top > group.zmax + 1e-6 ? `at z = ${group.zmax.toFixed(2)}` : bottom < group.zmin - 1e-6 ? `at z = ${group.zmin.toFixed(2)}` : `into a piece behind and a piece in front of ${group.group ? `the ${group.group.name} axis` : "the layer"}`;
          problems.push(`static ${item.name} interleaves live layer ${layer.name}: split ${item.name} ${where}, or move it`);
          continue;
        }
        if (proof.side !== "apart" && proof.side !== want) {
          problems.push(`static ${item.name} is ${proof.side === "behind" ? "behind" : "in front of"} live layer ${layer.name} (${proof.rule}) but its layer ${layerName} is ${want === "behind" ? "under" : "over"} it: move it to a layer ${proof.side === "behind" ? "under" : "over"} ${layer.name}`);
          continue;
        }
        proofs.push({ static: item.name, layer: layer.name, group: group.group ? group.group.name : "world", rule: proof.rule, side: proof.side });
        void label;
      }
    }
  }
  const live = T.layers.slice().sort((a, b) => rank.get(a.name) - rank.get(b.name));
  for (let i = 0; i < live.length; i++)
    for (let j = i + 1; j < live.length; j++)
      for (const lower of swept.get(live[i].name))
        for (const upper of swept.get(live[j].name)) {
          let ok = lower.zmax <= upper.zmin + 1e-6 ? "horizontal plane" : null;
          if (!ok && (lower.box[2] < upper.box[0] - 1 || upper.box[2] < lower.box[0] - 1 || lower.box[3] < upper.box[1] - 1 || upper.box[3] < lower.box[1] - 1)) ok = "never overlap";
          if (!ok) {
            const hit = separate(lower.envelope, upper.envelope, [[0, 0, 1]]);
            if (hit.margin >= -0.08 && dot3(hit.n, G.viewOf(T.P)) > 0) ok = "swept envelope";
          }
          if (!ok) problems.push(`live layer ${live[i].name} interleaves live layer ${live[j].name} (${lower.group ? lower.group.name : "world"} × ${upper.group ? upper.group.name : "world"}): move parts between them or reorder T.stack`);
          else proofs.push({ static: `layer ${live[i].name}`, layer: live[j].name, group: upper.group ? upper.group.name : "world", rule: ok, side: "behind" });
        }
  return { problems, proofs };
}

function build(T, { statics = [], verify = false, poses = null, strict = true, fullClouds = false } = {}) {
  const P = T.P;
  const started = Date.now();
  const states = statesOf(T, poses);
  const groups = groupData(T);
  const parts = T.parts.map(partData);
  const sweep = (layerParts, list) => {
    const data = { P, groups, layers: T.layers.map((layer) => ({ name: layer.name, parts: [], rest: [] })), parts };
    const scene = TURN.prepare(data, { unit: T.scale });
    const frames = scene.runs.map((run) => TURN.frame(run));
    return { scene, frames, data };
  };
  void sweep;
  const routes = T.routes.map((route) => ({ points: route.points.flat().map(r4), r: route.r, spacing: route.spacing, wide: route.wide, hue: route.hue, rings: route.rings }));
  const scratch = { P, groups, layers: T.layers.map((layer) => ({ name: layer.name, parts: [], rest: [] })), parts, routes };
  let scene = TURN.prepare(scratch, { unit: T.scale });
  const slotNeed = parts.map(() => ({ bevel: 0, ribs: 0 }));
  const sampleCams = new Map();
  const camsFor = (group) => {
    const key = group ? group.index : -1;
    if (!sampleCams.has(key)) {
      const list = groupStates(T, group, states, { tumble: true }).map((values) => TURN.cams(scene, values));
      sampleCams.set(key, list);
    }
    return sampleCams.get(key);
  };
  T.parts.forEach((part, index) => {
    const run = scene.runs[index];
    const needs = part.kind === "item" ? false : part.kind === "prism" ? Boolean(part.spec.bevel) : part.kind === "tube" ? Boolean(part.spec.rings) : Boolean(part.details.ribs);
    if (!needs) return;
    const g = part.group ? part.group.index : -1;
    const wide = { ...run, slots: { bevel: 64, ribs: 64 }, role: { ...run.role } };
    for (let j = 0; j < 64; j++) {
      wide.role[`v${j}`] = 1000 + j;
      wide.role[`r${j}`] = 2000 + j;
    }
    const f = TURN.frame(run);
    f.d = new Array(3000).fill("");
    f.o = new Float64Array(3000);
    f.q = new Int32Array(3000);
    f.w = new Float64Array(3000);
    f.t = new Array(3000).fill("");
    f.cx = new Float64Array(3000);
    f.cy = new Float64Array(3000);
    let most = 0;
    for (const cams of camsFor(part.group)) {
      TURN.emit(wide, g >= 0 ? cams[g] : cams.world, f);
      most = Math.max(most, f.levels ?? 0);
    }
    slotNeed[index][part.kind === "prism" ? "bevel" : "ribs"] = Math.max(1, most);
  });
  parts.forEach((data, index) => {
    data.spec = slotNeed[index].bevel || slotNeed[index].ribs ? { ...data.spec, slots: slotNeed[index] } : { ...data.spec };
  });
  scene = TURN.prepare({ ...scratch, parts }, { unit: T.scale });
  const centre = (list) => mul3(list.reduce((s, p) => add3(s, p), [0, 0, 0]), 1 / Math.max(1, list.length));
  const spheres = T.parts.map((part) => {
    const c = centre(part.samples);
    let r = 0;
    for (const p of part.samples) r = Math.max(r, G.len3(sub3(p, c)));
    return { c, r };
  });
  const planes = {};
  const dynamic = {};
  const free = {};
  let stateCamList = null;
  const stateCams = () => stateCamList ?? (stateCamList = states.map((values) => TURN.cams(scene, values)));
  const boxCache = new Map();
  const boxesOf = (part, list, key) => {
    const id = `${part.id}|${key}`;
    if (boxCache.has(id)) return boxCache.get(id);
    const local = thinCloud(part.samples, 40);
    const out = new Float64Array(4 * list.length + 1);
    const steps = [];
    list.forEach((cams, at) => {
      const c = part.group ? cams[part.group.index] : cams.world;
      let x0 = Infinity;
      let y0 = Infinity;
      let x1 = -Infinity;
      let y1 = -Infinity;
      for (const q of local) {
        const x = c.ox + c.m[0] * q[0] + c.m[1] * q[1] + c.m[2] * q[2];
        const y = c.oy + c.m[3] * q[0] + c.m[4] * q[1] + c.m[5] * q[2];
        if (x < x0) x0 = x;
        if (x > x1) x1 = x;
        if (y < y0) y0 = y;
        if (y > y1) y1 = y;
      }
      if (at) steps.push(Math.max(Math.abs(x0 - out[4 * at - 4]), Math.abs(y0 - out[4 * at - 3]), Math.abs(x1 - out[4 * at - 2]), Math.abs(y1 - out[4 * at - 1])));
      out.set([x0, y0, x1, y1], 4 * at);
    });
    steps.sort((x, y) => x - y);
    out[4 * list.length] = steps.length ? steps[Math.floor(steps.length * 0.95)] : 0;
    boxCache.set(id, out);
    return out;
  };
  const boxesMeet = (a, b, list, key) => {
    const A = boxesOf(a, list, key);
    const B = boxesOf(b, list, key);
    const pad = 1.5 + A[4 * list.length] + B[4 * list.length];
    for (let i = 0; i < list.length; i++) {
      const o = 4 * i;
      if (A[o] < B[o + 2] + pad && B[o] < A[o + 2] + pad && A[o + 1] < B[o + 3] + pad && B[o + 1] < A[o + 3] + pad) return true;
    }
    return false;
  };
  let framedCount = 0;
  const framedAll = {};
  const groupPairsAll = {};
  const ancestors = (g) => {
    const out = [];
    for (let x = g; x; x = x.parent) out.push(x);
    return out;
  };
  const commonOf = (g1, g2) => {
    const list = ancestors(g1);
    for (let x = g2; x; x = x.parent) if (list.includes(x)) return x;
    return null;
  };
  const sampleStates = () => {
    const all = stateCams();
    const count = Math.min(all.length, 24);
    return Array.from({ length: count }, (_, i) => all[Math.floor((i * all.length) / count)]);
  };
  const toFrame = (dir, from, frame) => {
    if (from === frame) return dir;
    const cams = sampleStates()[0] ?? TURN.cams(scene, {});
    const Rf = from ? cams[from.index].R : [1, 0, 0, 0, 1, 0, 0, 0, 1];
    const w = [Rf[0] * dir[0] + Rf[1] * dir[1] + Rf[2] * dir[2], Rf[3] * dir[0] + Rf[4] * dir[1] + Rf[5] * dir[2], Rf[6] * dir[0] + Rf[7] * dir[1] + Rf[8] * dir[2]];
    const Rc = frame ? cams[frame.index].R : [1, 0, 0, 0, 1, 0, 0, 0, 1];
    return [Rc[0] * w[0] + Rc[3] * w[1] + Rc[6] * w[2], Rc[1] * w[0] + Rc[4] * w[1] + Rc[7] * w[2], Rc[2] * w[0] + Rc[5] * w[1] + Rc[8] * w[2]];
  };
  const cloudCache = new Map();
  const framedCloud = (part, frame) => {
    const key = `${part.id}|${frame ? frame.index : -1}`;
    if (cloudCache.has(key)) return cloudCache.get(key);
    const local = thinCloud(part.samples, 32);
    const out = [];
    for (const cams of sampleStates()) {
      const c = part.group ? cams[part.group.index] : cams.world;
      const f = frame ? cams[frame.index] : cams.world;
      for (const q of local) {
        const p = [c.R[0] * q[0] + c.R[1] * q[1] + c.R[2] * q[2] + c.t[0] - f.t[0], c.R[3] * q[0] + c.R[4] * q[1] + c.R[5] * q[2] + c.t[1] - f.t[1], c.R[6] * q[0] + c.R[7] * q[1] + c.R[8] * q[2] + c.t[2] - f.t[2]];
        out.push([f.R[0] * p[0] + f.R[3] * p[1] + f.R[6] * p[2], f.R[1] * p[0] + f.R[4] * p[1] + f.R[7] * p[2], f.R[2] * p[0] + f.R[5] * p[1] + f.R[8] * p[2]]);
      }
    }
    cloudCache.set(key, out);
    return out;
  };
  const skip = {};
  const problems = [];
  const margins = [];
  const planesByName = new Map();
  const layerData = [];
  for (const layer of T.layers) {
    const members = T.parts.filter((part) => part.layer === layer);
    const plan = [];
    const dyn = [];
    const framed = [];
    const skipped = [];
    const pairsOfGroups = [];
    const settled = new Set();
    const byGroup = new Map();
    for (const part of members) {
      if (part.group && part.group.loose) continue;
      const key = part.group ? part.group.index : -1;
      if (!byGroup.has(key)) byGroup.set(key, []);
      byGroup.get(key).push(part);
    }
    const keys = [...byGroup.keys()];
    if (states.length)
      for (let x = 0; x < keys.length; x++)
        for (let y = x + 1; y < keys.length; y++) {
          const A = byGroup.get(keys[x]);
          const B = byGroup.get(keys[y]);
          const ga = keys[x] >= 0 ? T.groups[keys[x]] : null;
          const gb = keys[y] >= 0 ? T.groups[keys[y]] : null;
          const common = commonOf(ga, gb);
          const ca = A.flatMap((part) => framedCloud(part, common).filter((_, i) => fullClouds || i % 2 === 0));
          const cb = B.flatMap((part) => framedCloud(part, common).filter((_, i) => fullClouds || i % 2 === 0));
          const hit = quickSeparate(ca, cb, [[0, 0, 1], ...A.flatMap((part) => part.hints).slice(0, 6).map((h) => toFrame(h, ga, common)), ...B.flatMap((part) => part.hints).slice(0, 6).map((h) => toFrame(h, gb, common))]);
          if (hit.margin < 0.1) continue;
          pairsOfGroups.push(keys[x], keys[y], common ? common.index : -1, hit.n[0], hit.n[1], hit.n[2]);
          settled.add(`${keys[x]}|${keys[y]}`);
          settled.add(`${keys[y]}|${keys[x]}`);
        }
    groupPairsAll[layer.name] = pairsOfGroups;
    for (let x = 0; x < members.length; x++)
      for (let y = x + 1; y < members.length; y++) {
        const a = members[x];
        const b = members[y];
        if (a.route && a.route === b.route && Math.abs(a.chunk - b.chunk) <= 1) {
          skipped.push(a.id, b.id);
          continue;
        }
        const sa = spheres[a.id];
        const sb = spheres[b.id];
        if (a.group === b.group) {
          const g = a.group ? a.group.index : -1;
          const reach = G.len3(sub3(sb.c, sa.c)) + sa.r + sb.r;
          let near = false;
          void reach;
          if (a.group && a.group.loose) near = G.len3(sub3(sb.c, sa.c)) < sa.r + sb.r + 1;
          else near = boxesMeet(a, b, camsFor(a.group), `g${g}`);
          if (!near) continue;
          const hit = separate(a.samples, b.samples, [[0, 0, 1], ...a.hints, ...b.hints]);
          margins.push({ a: a.name, b: b.name, margin: hit.margin });
          const o = a.group ? a.group.origin : [0, 0, 0];
          planesByName.set(`${a.name}|${b.name}`, { n: hit.n, d: hit.d + dot3(hit.n, o) });
          if (hit.margin < -0.08) {
            problems.push(`interlock ${a.name} × ${b.name} ${hit.margin.toFixed(2)}: split, flats or a flush contact`);
            if (strict) continue;
          }
          plan.push(a.id, b.id, hit.n[0], hit.n[1], hit.n[2]);
        } else if ((a.group && a.group.loose) || (b.group && b.group.loose)) {
          continue;
        } else if (settled.has(`${a.group ? a.group.index : -1}|${b.group ? b.group.index : -1}`)) {
          continue;
        } else {
          const near = !states.length || boxesMeet(a, b, stateCams(), "states");
          if (!near) continue;
          const common = commonOf(a.group, b.group);
          const ca = framedCloud(a, common);
          const cb = framedCloud(b, common);
          const hit = quickSeparate(ca, cb, [[0, 0, 1], ...a.hints, ...b.hints].map((h) => toFrame(h, a.group, common)).concat([[0, 0, 1], ...b.hints.map((h) => toFrame(h, b.group, common))]));
          if (hit.margin >= 0.1) {
            framed.push(a.id, b.id, common ? common.index : -1, hit.n[0], hit.n[1], hit.n[2]);
            framedCount++;
          } else dyn.push(a.id, b.id);
        }
      }
    planes[layer.name] = plan;
    dynamic[layer.name] = dyn;
    framedAll[layer.name] = framed;
    free[layer.name] = members.filter((part) => part.group && part.group.loose).map((part) => part.id);
    skip[layer.name] = skipped;
    layerData.push({ name: layer.name, parts: members.map((part) => part.id), rest: [] });
  }
  if (problems.length && strict) throw new Error(`turning: ${problems.length} pair${problems.length === 1 ? "" : "s"} with no separating plane\n  ${problems.join("\n  ")}`);
  const layered = proveLayers(T, statics, states, scene);
  if (layered.problems.length) throw new Error(`turning: ${layered.problems.length} layer problem${layered.problems.length === 1 ? "" : "s"}\n  ${layered.problems.join("\n  ")}`);
  const values = Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "free" ? null : 0]));
  const data = { P, groups, layers: layerData, parts, routes, planes: packPairs(planes, 5, false), framed: packPairs(framedAll, 6, true), groupPairs: pack(groupPairsAll), dynamic, free, skip, values };
  const clouds = T.parts.map((part) => thinCloud(part.samples, 64));
  data.parts.forEach((p, index) => {
    if (p.kind === "fixed") p.cloud = clouds[index].flat().map(r4);
  });
  const packed = { ...pack({ ...data, parts: [] }), parts: pack3(data.parts) };
  scene = TURN.prepare(packed, { unit: T.scale });
  const cams = TURN.cams(scene, values);
  const frames = scene.runs.map((run) => TURN.emit(run, run.group >= 0 ? cams[run.group] : cams.world, TURN.frame(run)));
  const V = G.viewOf(P);
  scene.layers.forEach((layer, index) => {
    const provisional = Int32Array.from(Array.from(layer.parts).sort((i, j) => dot3(spheres[i].c, V) - dot3(spheres[j].c, V)));
    const order = TURN.order(layer, frames, cams, provisional, {}, scene);
    packed.layers[index].rest = Array.from(order);
    layer.rest = order;
  });
  const markup = {};
  scene.layers.forEach((layer, index) => {
    markup[layer.name] = `<g class="iso-parts" data-layer="${esc(layer.name)}">${packed.layers[index].rest.map((i) => partMarkup(T.parts[i], scene.runs[i], frames[i])).join("")}</g>`;
  });
  const report = {
    parts: T.parts.length,
    groups: T.groups.length,
    layers: T.layers.map((layer) => ({ name: layer.name, parts: packed.layers[layer.index].parts.length, planar: planes[layer.name].length / 5, framed: framedAll[layer.name].length / 6, groupPairs: groupPairsAll[layer.name].length / 6, dynamic: dynamic[layer.name].length / 2, skip: skipped(skip[layer.name]) })),
    tight: margins.filter((m) => m.margin < 0).sort((a, b) => a.margin - b.margin),
    margins,
    proofs: layered.proofs,
    seconds: (Date.now() - started) / 1000,
  };
  const out = { svg: markup, data: packed, css: TURN_CSS, report, scene, frames, planesByName };
  out.layerSvg = (name, { width, height, label = "" }) => `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg iso-live iso-layer" data-live="${esc(name)}" viewBox="0 0 ${width} ${height}" aria-hidden="true"${label ? ` aria-label="${esc(label)}"` : ""}>${markup[name]}</svg>`;
  if (verify) out.verify = verifyData(T, statics);
  T.built = out;
  T.data = packed;
  return out;
}

const skipped = (list) => list.length / 2;

function attrs(pairs) {
  return Object.entries(pairs)
    .filter(([, v]) => v !== undefined && v !== null && v !== false)
    .map(([key, v]) => ` ${key}="${esc(v)}"`)
    .join("");
}

function roleMarkup(part, run, f, r) {
  const role = run.roles[r];
  if (role.type === "path") return `<path${attrs({ class: role.cls, "data-shade": role.shade === undefined ? undefined : String(role.shade), "data-tone": role.tone, "data-r": role.name, d: f.d[r] })}/>`;
  if (role.type === "face") {
    const q = f.q[r];
    const band = Math.min(3, Math.floor(q / TURN.q));
    const mix = (q - TURN.q * band) / TURN.q;
    const style = [mix ? `--f:${mix}` : "", f.w[r] >= 0 ? `stroke-width:${f.w[r]}` : ""].filter(Boolean).join(";");
    return `<path${attrs({ class: "iso-tn", "data-r": role.name, "data-b": band ? String(band) : undefined, style: style || undefined, d: f.d[r] })}/>`;
  }
  if (role.type === "slot") return `<path${attrs({ class: role.cls, "data-tone": role.tone, "data-r": role.name, opacity: String(Math.round(f.o[r] * 1000) / 1000), d: f.d[r] })}/>`;
  if (role.type === "xform") return `<path${attrs({ class: role.cls, "data-r": role.name, transform: f.t[r], d: part.planPath })}/>`;
  if (role.type === "plane") {
    const index = Number(role.name.slice(5));
    return `<g${attrs({ "data-r": role.name, transform: f.t[r], opacity: String(f.o[r]) })}>${part.planes[index].svg}</g>`;
  }
  return "";
}

function dotsMarkup(part, run, f) {
  let out = "";
  let open = null;
  run.roles.forEach((role, r) => {
    if (role.type !== "dot") return;
    if (open !== role.tone) {
      if (open !== null) out += "</g>";
      out += `<g class="iso-dots" data-tone="${role.tone}">`;
      open = role.tone;
    }
    out += `<circle data-r="${role.name}" cx="${Math.round(f.cx[r] * 100) / 100}" cy="${Math.round(f.cy[r] * 100) / 100}" r="${role.size}" fill-opacity="${f.o[r]}"/>`;
  });
  return open !== null ? out + "</g>" : out;
}

function partMarkup(part, run, f) {
  if (part.kind === "item") return `<g class="iso-item" data-p="${run.index}" data-name="${esc(part.name)}"></g>`;
  const head = (cls, extra = {}) => `<g${attrs({ class: cls, "data-p": String(run.index), "data-name": part.name, "data-mat": part.material ?? undefined, "data-lit": part.lit ? "" : undefined, ...extra })}>`;
  const inner = run.roles.map((role, r) => (role.type === "dot" || role.type === "self" ? "" : roleMarkup(part, run, f, r))).join("");
  if (part.kind === "tube") return head("tb", { "data-hue": part.hue ?? undefined }) + inner + "</g>";
  if (part.kind === "fixed") {
    const at = part.svg.lastIndexOf('<path class="iso-line iso-edge"');
    const body = at >= 0 ? part.svg.slice(0, at) + inner + part.svg.slice(at) : part.svg + inner;
    return head("iso-part", { transform: f.t[run.role.move] || undefined }) + body + dotsMarkup(part, run, f) + "</g>";
  }
  return head("iso-solid", { "data-seamless": part.kind === "round" || part.kind === "ball" || part.kind === "lathe" ? "" : undefined }) + inner + dotsMarkup(part, run, f) + "</g>";
}

function verifyData(T, statics = []) {
  const order = T.order.length ? T.order : T.layers.map((layer) => layer.name);
  const live = new Set(T.layers.map((layer) => layer.name));
  const staticLayers = order.filter((name) => !live.has(name));
  const liveShapes = new Set(T.parts.flatMap((part) => part.shapes));
  return {
    parts: T.parts.map((part) => ({ name: part.name, group: part.group ? part.group.index : -1, layer: part.layer.index, shapes: part.shapes.map((shape) => localShape(shape, part.group)) })),
    statics: statics.filter((item) => item.shapes && item.shapes.length && staticLayers.includes(item.layer)).map((item) => ({ name: item.name, rank: staticLayers.indexOf(item.layer), shapes: item.shapes.filter((shape) => !liveShapes.has(shape)).map((shape) => localShape(shape, null)) })),
  };
}

function localShape(shape, group) {
  const o = group ? group.origin : [0, 0, 0];
  const out = { kind: shape.kind };
  if (shape.F) out.F = { o: sub3(shape.F.o, o), a: shape.F.a, u: shape.F.u, v: shape.F.v };
  for (const key of ["poly", "s0", "s1", "r", "radii", "flats"]) if (shape[key] !== undefined) out[key] = shape[key];
  if (shape.o && shape.kind !== "body" && shape.kind !== "prism") out.o = sub3(shape.o, o);
  if (shape.points) out.points = shape.points.map((p) => sub3(p, o));
  if (shape.axes) out.axes = shape.axes;
  if (shape.half) out.half = shape.half;
  return pack(out);
}

export { pack, sampleShape };
```

## kit/turn-fixed.mjs

```js
const T3_ROOT = Math.sqrt(1.6);
const T3_TAU = Math.PI * 2;
const T3_RAD = Math.PI / 180;
const T3_LB = [0, 0.2, 0.42, 0.62];
const T3_EB = [0.25, 0.5, 0.75];
const T3_SOFT = 0.035;
const T3_LEVELS = 32;
const T3_Q = 64;
const t3State = { detail: 1, exact: false };

function t3Num(v) {
  if (t3State.exact) return String(Math.round(v * 1e6) / 1e6);
  let t = Math.round(v * 100);
  if (t === 0) return "0";
  let sign = "";
  if (t < 0) {
    sign = "-";
    t = -t;
  }
  const whole = Math.floor(t / 100);
  const part = t - whole * 100;
  if (part === 0) return sign + whole;
  if (part < 10) return sign + whole + ".0" + part;
  return sign + whole + "." + (part % 10 === 0 ? part / 10 : part);
}

function t3Fix(v, scale) {
  const t = Math.round(v * scale) / scale;
  return t === 0 ? "0" : String(t);
}

const t3Arena = { on: false, f: null };

function t3Token(get, count, closed) {
  const f = t3Arena.f;
  const at = f.arenaN;
  let arena = f.arena;
  if (!arena || arena.length < 2 * (at + count)) {
    const next = new Float64Array(Math.max(512, 2 * (at + count) * 2));
    if (arena) next.set(arena.subarray(0, 2 * at));
    arena = f.arena = next;
  }
  const unit = t3State.exact ? 1e6 : 100;
  for (let i = 0; i < count; i++) {
    arena[2 * (at + i)] = Math.round(get(2 * i) * unit) / unit;
    arena[2 * (at + i) + 1] = Math.round(get(2 * i + 1) * unit) / unit;
  }
  f.arenaN = at + count;
  return "\u0001" + String.fromCharCode(at & 0xffff, at >>> 16, count, closed ? 1 : 0);
}

function t3Matrix(f, role, a, b, c, d, e, g) {
  const list = f.tm || (f.tm = []);
  const m = list[role] || (list[role] = new Float64Array(6));
  m[0] = Math.round(a * 1e5) / 1e5;
  m[1] = Math.round(b * 1e5) / 1e5;
  m[2] = Math.round(c * 1e5) / 1e5;
  m[3] = Math.round(d * 1e5) / 1e5;
  m[4] = Math.round(e * 100) / 100;
  m[5] = Math.round(g * 100) / 100;
  return "\u0002";
}

function t3Path(buf, count, closed) {
  if (count < 2) return "";
  if (t3Arena.on) return t3Token((i) => buf[i], count, closed);
  let s = "M" + t3Num(buf[0]) + " " + t3Num(buf[1]);
  for (let i = 1; i < count; i++) s += "L" + t3Num(buf[2 * i]) + " " + t3Num(buf[2 * i + 1]);
  return closed ? s + "Z" : s;
}

const t3Step = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

function t3Level(score, bands) {
  let u = 0;
  for (let i = 0; i < bands.length; i++) u += t3Step(bands[i] - T3_SOFT, bands[i] + T3_SOFT, score);
  return Math.round(u * T3_Q);
}

function t3Grow(f, name, size, Kind) {
  if (!f[name] || f[name].length < size) f[name] = new Kind(Math.max(size, f[name] ? f[name].length * 2 : 16));
  return f[name];
}

function t3Box(buf, count, box) {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (let i = 0; i < count; i++) {
    const x = buf[2 * i];
    const y = buf[2 * i + 1];
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  box[0] = x0;
  box[1] = y0;
  box[2] = x1;
  box[3] = y1;
}

function t3Unit(v) {
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
  return [v[0] / l, v[1] / l, v[2] / l];
}

function t3Rot(axis, degrees) {
  const [x, y, z] = t3Unit(axis);
  const c = Math.cos(degrees * T3_RAD);
  const s = Math.sin(degrees * T3_RAD);
  const C = 1 - c;
  return new Float64Array([c + x * x * C, x * y * C - z * s, x * z * C + y * s, y * x * C + z * s, c + y * y * C, y * z * C - x * s, z * x * C - y * s, z * y * C + x * s, c + z * z * C]);
}

function t3Mul(A, B) {
  const out = new Float64Array(9);
  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];
  return out;
}

const t3Apply = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];

const T3_EYE = new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);

function t3Camera(P, pose) {
  const a = (P.azimuth ?? 45) * T3_RAD;
  const e = (P.elevation ?? 30) * T3_RAD;
  const k = P.scale * T3_ROOT;
  const sa = Math.sin(a);
  const ca = Math.cos(a);
  const se = Math.sin(e);
  const ce = Math.cos(e);
  const cam = { m: new Float64Array(6), ox: 0, oy: 0, V: [0, 0, 0], L: [0, 0, 0], k, turn: 0, R: T3_EYE, t: [0, 0, 0], lx: 0, ly: 1, sa, ca, se, ce, origin: P.origin };
  const lw = Math.hypot(sa, ca, 1.2);
  if (pose && pose.R && !(pose.turn !== undefined && pose.root)) {
    const R = pose.R;
    const t = pose.t ?? [0, 0, 0];
    const r0 = [sa * k, -ca * k, 0];
    const r1 = [ca * se * k, sa * se * k, -ce * k];
    for (let c = 0; c < 3; c++) {
      cam.m[c] = r0[0] * R[c] + r0[1] * R[3 + c] + r0[2] * R[6 + c];
      cam.m[3 + c] = r1[0] * R[c] + r1[1] * R[3 + c] + r1[2] * R[6 + c];
    }
    cam.ox = P.origin[0] + r0[0] * t[0] + r0[1] * t[1] + r0[2] * t[2];
    cam.oy = P.origin[1] + r1[0] * t[0] + r1[1] * t[1] + r1[2] * t[2];
    const V = [ca * ce, sa * ce, se];
    const L = [-sa / lw, ca / lw, 1.2 / lw];
    for (let c = 0; c < 3; c++) {
      cam.V[c] = R[c] * V[0] + R[3 + c] * V[1] + R[6 + c] * V[2];
      cam.L[c] = R[c] * L[0] + R[3 + c] * L[1] + R[6 + c] * L[2];
    }
    cam.R = R;
    cam.t = t;
  } else {
    const turn = pose ? pose.turn ?? 0 : 0;
    const pivot = pose && pose.pivot ? pose.pivot : [0, 0];
    const b = a - turn * T3_RAD;
    const sb = Math.sin(b);
    const cb = Math.cos(b);
    cam.m[0] = sb * k;
    cam.m[1] = -cb * k;
    cam.m[2] = 0;
    cam.m[3] = cb * se * k;
    cam.m[4] = sb * se * k;
    cam.m[5] = -ce * k;
    cam.ox = P.origin[0] + (pivot[0] * sa - pivot[1] * ca) * k;
    cam.oy = P.origin[1] + (pivot[0] * ca + pivot[1] * sa) * se * k;
    cam.V = [cb * ce, sb * ce, se];
    cam.L = [-sb / lw, cb / lw, 1.2 / lw];
    cam.turn = turn;
    const c = Math.cos(turn * T3_RAD);
    const s = Math.sin(turn * T3_RAD);
    cam.R = new Float64Array([c, -s, 0, s, c, 0, 0, 0, 1]);
    cam.t = [pivot[0], pivot[1], 0];
  }
  const lh = Math.hypot(cam.L[0], cam.L[1]) || 1;
  cam.lx = cam.L[0] / lh;
  cam.ly = cam.L[1] / lh;
  return cam;
}

function t3Quat(q) {
  const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
  const x = q[0] / l;
  const y = q[1] / l;
  const z = q[2] / l;
  const w = q[3] / l;
  return new Float64Array([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]);
}

function t3FreeRot(v) {
  if (v.R) return v.R instanceof Float64Array ? v.R : Float64Array.from(v.R);
  if (v.q) return t3Quat(v.q);
  if (v.axis) return t3Rot(v.axis, v.angle ?? 0);
  return T3_EYE;
}

function t3Poses(groups, values) {
  const get = (name) => (values instanceof Map ? values.get(name) : values ? values[name] : undefined) ?? 0;
  const list = [];
  for (let index = 0; index < groups.length; index++) {
    const g = groups[index];
    const v = get(g.name);
    let parent = g.parent === undefined || g.parent === null || g.parent < 0 ? null : list[g.parent];
    let po = parent ? groups[g.parent].origin : [0, 0, 0];
    let R = T3_EYE;
    let t = [g.origin[0] - po[0], g.origin[1] - po[1], g.origin[2] - po[2]];
    if (g.kind === "turn") R = t3Rot([0, 0, 1], v);
    else if (g.kind === "hinge") R = t3Rot(g.axis, v);
    else if (g.kind === "slide") {
      const d = t3Unit(g.direction);
      t[0] += d[0] * v;
      t[1] += d[1] * v;
      t[2] += d[2] * v;
    } else if (g.kind === "free" && v && typeof v === "object") {
      R = t3FreeRot(v);
      if (v.world) {
        parent = null;
        po = [0, 0, 0];
        t = [g.origin[0], g.origin[1], g.origin[2]];
      }
      if (v.t) {
        t[0] += v.t[0];
        t[1] += v.t[1];
        t[2] += v.t[2];
      }
    }
    if (!parent) {
      const pose = { R, t, value: v };
      if (g.kind === "turn") Object.assign(pose, { turn: v, pivot: [g.origin[0], g.origin[1]], root: true });
      list.push(pose);
      continue;
    }
    const tw = t3Apply(parent.R, t);
    list.push({ R: t3Mul(parent.R, R), t: [tw[0] + parent.t[0], tw[1] + parent.t[1], tw[2] + parent.t[2]], value: v });
  }
  return list;
}

function t3PosesMap(groups, values) {
  const list = t3Poses(groups, values);
  return new Map(groups.map((g, index) => [g.name, list[index]]));
}

function t3Roles(part) {
  const roles = [];
  const spec = part.spec;
  const details = part.details ?? {};
  const add = (name, type, extra = {}) => roles.push({ name, type, ...extra });
  const tone = part.tone ?? "hi";
  const crease = part.crease ?? "faint";
  const fadedSlots = (prefix, count, cls, lineTone) => {
    for (let j = 0; j < count; j++) add(`${prefix}${j}`, "slot", { cls, tone: lineTone });
  };
  const planes = () => (part.planes ?? []).forEach((_, j) => add(`plane${j}`, "plane"));
  const dots = () => (part.dots ?? []).forEach((dot, j) => add(`c${j}`, "dot", { tone: dot.tone ?? "mid", size: dot.size ?? 0.5 }));
  const sheets = (top) => {
    for (let t = 0; t < 4; t++) add(`s${t}`, "path", { cls: "iso-shade", shade: t });
    if (top) add("s4", "path", { cls: "iso-top" });
  };
  const inner = () => {
    if ((details.seams && details.seams.length) || (details.ribs && details.ribs.seams)) add("inner", "path", { cls: "iso-line", tone: "faint" });
  };
  const ribs = () => {
    if (details.ribs) fadedSlots("r", spec.slots?.ribs ?? 6, "iso-line", details.ribs.tone ?? "lo");
  };
  if (part.kind === "prism") {
    add("fill", "path", { cls: "iso-fill" });
    const faces = spec.ring.length / 2 + (spec.up ? 0 : 2);
    for (let j = 0; j < faces; j++) add(`f${j}`, "face");
    if (spec.up) add("top", "xform", { cls: "iso-top" });
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    if (spec.bevel && crease !== "none") fadedSlots("v", spec.slots?.bevel ?? 6, "iso-line iso-bevel", crease);
    if (details.seams && details.seams.length) add("inner", "path", { cls: "iso-line", tone: "faint" });
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "round" || part.kind === "lathe") {
    add("fill", "path", { cls: "iso-fill" });
    sheets(true);
    const ends = part.kind === "round" ? spec.ends : spec.caps.map((cap) => (cap ? "flat" : "open"));
    ends.forEach((end, j) => end === "flat" && add(`k${j}`, "face"));
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    inner();
    ribs();
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "ball") {
    add("fill", "path", { cls: "iso-fill" });
    sheets(false);
    spec.flats.forEach((_, j) => add(`f${j}`, "face"));
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "tube") {
    add("body", "path", { cls: "tb-body" });
    if (spec.wide) {
      add("shine", "path", { cls: "tb-shine" });
      add("shade", "path", { cls: "tb-shade" });
    }
    if (spec.rings) fadedSlots("r", spec.slots?.ribs ?? 6, "tb-ring", spec.rings.tone ?? "lo");
    add("edges", "path", { cls: "iso-line tb-edge", tone });
  } else if (part.kind === "item") {
    return roles;
  } else if (part.kind === "fixed") {
    add("move", "self");
    ribs();
    planes();
    dots();
  }
  return roles;
}

function t3Planes(list) {
  return (list ?? []).map((plane) => ({ o: plane.o, u: plane.u, v: plane.v, n: plane.n, fade: plane.fade ?? [0.04, 0.3], billboard: Boolean(plane.billboard) }));
}

function t3Dots(list) {
  return (list ?? []).map((dot) => ({ p: dot.p, n: dot.n ?? null, size: dot.size ?? 0.5, fade: dot.fade ?? [0.04, 0.42] }));
}

function t3PreparePrism(run, part) {
  const spec = part.spec;
  const n = spec.ring.length / 2;
  run.n = n;
  run.up = Boolean(spec.up);
  const ring = Float64Array.from(spec.ring);
  run.ring = ring;
  const nx = new Float64Array(n);
  const ny = new Float64Array(n);
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n;
    const dx = ring[2 * j] - ring[2 * i];
    const dy = ring[2 * j + 1] - ring[2 * i + 1];
    const l = Math.hypot(dx, dy) || 1;
    nx[i] = dy / l;
    ny[i] = -dx / l;
  }
  run.nx = nx;
  run.ny = ny;
  const F = spec.up ? { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] } : spec.F;
  run.F = F;
  run.s0 = spec.up ? spec.z0 : spec.s0;
  run.s1 = spec.up ? spec.z1 : spec.s1;
  const lo = new Float64Array(3 * n);
  const hi = new Float64Array(3 * n);
  const N3 = new Float64Array(3 * n);
  for (let i = 0; i < n; i++) {
    const x = ring[2 * i];
    const y = ring[2 * i + 1];
    for (let c = 0; c < 3; c++) {
      const base = F.o[c] + F.u[c] * x + F.v[c] * y;
      lo[3 * i + c] = base + F.a[c] * run.s0;
      hi[3 * i + c] = base + F.a[c] * run.s1;
      N3[3 * i + c] = F.u[c] * nx[i] + F.v[c] * ny[i];
    }
  }
  run.lo = lo;
  run.hi = hi;
  run.N3 = N3;
  run.sharp = new Uint8Array(n);
  for (let i = 0; i < n; i++) {
    const p = (i - 1 + n) % n;
    run.sharp[i] = nx[p] * nx[i] + ny[p] * ny[i] < Math.cos(0.35) ? 1 : 0;
  }
  if (spec.bevel) {
    const inset = new Float64Array(2 * n);
    for (let i = 0; i < n; i++) {
      const p = (i - 1 + n) % n;
      const mx = nx[p] + nx[i];
      const my = ny[p] + ny[i];
      const l = Math.hypot(mx, my) || 1;
      inset[2 * i] = ring[2 * i] - (mx / l) * spec.bevel;
      inset[2 * i + 1] = ring[2 * i + 1] - (my / l) * spec.bevel;
    }
    run.inset = inset;
  }
  run.seams = part.details && part.details.seams ? part.details.seams.slice() : null;
  run.cut = spec.cuts && spec.cuts.length ? spec.cuts : null;
}

function t3Axes(run, part) {
  if (run.kind === "ball") return run.flats.map((flat) => flat.n);
  if (run.kind === "tube") return run.tips.slice();
  if (run.kind === "prism") {
    const out = [run.F.a];
    for (let i = 0; i < run.n; i++) {
      const n = [run.N3[3 * i], run.N3[3 * i + 1], run.N3[3 * i + 2]];
      if (out.every((m) => Math.abs(m[0] * n[0] + m[1] * n[1] + m[2] * n[2]) < 0.9995)) out.push(n);
    }
    return out;
  }
  if (run.F) return [run.F.a];
  if (part.spec && part.spec.F) return [part.spec.F.a];
  return [];
}

function t3Cloud(run, part) {
  const out = [];
  const ring = (F, s, r, count) => {
    for (let j = 0; j < count; j++) {
      const c = Math.cos((j / count) * T3_TAU);
      const n = Math.sin((j / count) * T3_TAU);
      out.push(F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]), F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]), F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]));
    }
  };
  if (run.kind === "prism") {
    for (let i = 0; i < 3 * run.n; i++) out.push(run.lo[i]);
    for (let i = 0; i < 3 * run.n; i++) out.push(run.hi[i]);
  } else if (run.kind === "round") {
    ring(run.F, run.s0, run.r0, 24);
    ring(run.F, run.s1, run.r1, 24);
    for (const [end, s, r] of [[0, run.s0, run.r0], [1, run.s1, run.r1]]) {
      if (run.ends[end] !== "dome") continue;
      const sign = end ? 1 : -1;
      for (const b of [0.5, 1.0, 1.35]) ring(run.F, s + sign * r * Math.sin(b), r * Math.cos(b), 6);
      out.push(run.F.o[0] + run.F.a[0] * (s + sign * r), run.F.o[1] + run.F.a[1] * (s + sign * r), run.F.o[2] + run.F.a[2] * (s + sign * r));
    }
  } else if (run.kind === "ball") {
    const golden = Math.PI * (3 - Math.sqrt(5));
    for (let i = 0; i < 64; i++) {
      const y = 1 - (2 * (i + 0.5)) / 64;
      const rad = Math.sqrt(1 - y * y);
      const w = [Math.cos(golden * i) * rad, Math.sin(golden * i) * rad, y];
      const p = [run.c[0] + run.r * w[0], run.c[1] + run.r * w[1], run.c[2] + run.r * w[2]];
      let inside = true;
      for (const flat of run.flats) if ((p[0] - run.c[0]) * flat.n[0] + (p[1] - run.c[1]) * flat.n[1] + (p[2] - run.c[2]) * flat.n[2] > flat.d + 1e-9) inside = false;
      if (inside) out.push(p[0], p[1], p[2]);
    }
    for (const flat of run.flats)
      for (let j = 0; j < 12; j++) {
        const c = Math.cos((j / 12) * T3_TAU) * flat.rho;
        const n = Math.sin((j / 12) * T3_TAU) * flat.rho;
        out.push(run.c[0] + flat.n[0] * flat.d + c * flat.u[0] + n * flat.v[0], run.c[1] + flat.n[1] * flat.d + c * flat.u[1] + n * flat.v[1], run.c[2] + flat.n[2] * flat.d + c * flat.u[2] + n * flat.v[2]);
      }
  } else if (run.kind === "lathe") {
    const lv = run.levels;
    const step = Math.max(4, Math.floor(lv.length / 4 / 8)) * 4;
    for (let i = 0; i < lv.length; i += step) ring(run.F, lv[i], lv[i + 1], 8);
    ring(run.F, lv[lv.length - 4], lv[lv.length - 3], 8);
  } else if (run.kind === "tube") {
    const b = run.body;
    const stride = Math.max(1, Math.floor(b.count / 8));
    for (let i = 0; i < b.count; i += stride) {
      const T = t3Unit([b.D3[3 * i], b.D3[3 * i + 1], b.D3[3 * i + 2]]);
      const [u, v] = t3Basis(T);
      ring({ o: [b.P3[3 * i], b.P3[3 * i + 1], b.P3[3 * i + 2]], a: T, u, v }, 0, run.r, 8);
    }
  } else if (part.cloud) for (const v of part.cloud) out.push(v);
  return Float64Array.from(out);
}

function t3Unpack(value, grouped) {
  if (!value) return new Float64Array(0);
  if (typeof value !== "string") return Float64Array.from(value);
  const text = atob(value);
  const bytes = new Uint8Array(text.length);
  for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
  const view = new DataView(bytes.buffer);
  const size = grouped ? 10 : 8;
  const stride = grouped ? 6 : 5;
  const count = bytes.length / size;
  const out = new Float64Array(count * stride);
  for (let e = 0; e < count; e++) {
    let o = e * size;
    const at = e * stride;
    out[at] = view.getUint16(o);
    out[at + 1] = view.getUint16(o + 2);
    o += 4;
    if (grouped) {
      out[at + 2] = view.getUint16(o) - 1;
      o += 2;
    }
    let x = (view.getUint16(o) / 65535) * 2 - 1;
    let y = (view.getUint16(o + 2) / 65535) * 2 - 1;
    const z = 1 - Math.abs(x) - Math.abs(y);
    if (z < 0) {
      const ox = (1 - Math.abs(y)) * (x >= 0 ? 1 : -1);
      const oy = (1 - Math.abs(x)) * (y >= 0 ? 1 : -1);
      x = ox;
      y = oy;
    }
    const l = Math.hypot(x, y, z) || 1;
    out[at + stride - 3] = x / l;
    out[at + stride - 2] = y / l;
    out[at + stride - 1] = z / l;
  }
  return out;
}

function t3Prepare(data, { unit = 1 } = {}) {
  const P = data.P;
  const runs = data.parts.map((part, index) => {
    const run = { index, name: part.name, kind: part.kind, group: part.group ?? -1, layer: part.layer, part, roles: t3Roles(part), planes: t3Planes(part.planes), dots: t3Dots(part.dots), unit };
    run.role = Object.fromEntries(run.roles.map((role, j) => [role.name, j]));
    if (part.kind === "prism") t3PreparePrism(run, part);
    else if (part.kind === "round") t3PrepareRound(run, part, P, unit);
    else if (part.kind === "ball") t3PrepareBall(run, part, P, unit);
    else if (part.kind === "lathe") t3PrepareLathe(run, part, P, unit);
    else if (part.kind === "tube") t3PrepareTube(run, part, data);
    else if (part.kind === "item") {
      run.version = 0;
      run.itemBox = new Float64Array([1e9, 1e9, -1e9, -1e9]);
      run.itemHull = new Float64Array(0);
    } else if (part.kind === "fixed") {
      run.anchor = part.spec.anchor;
      run.home = part.spec.home;
      run.box0 = part.spec.box;
      run.hull0 = Float64Array.from(part.spec.hull);
      run.F = part.spec.F ?? null;
      run.ribs = part.details && part.details.ribs ? part.details.ribs : null;
    }
    run.slots = part.spec.slots ?? {};
    run.cloud = t3Cloud(run, part);
    run.axes = t3Axes(run, part);
    const cn = run.cloud.length / 3;
    run.centroid = [0, 0, 0];
    for (let i = 0; i < cn; i++) for (let c = 0; c < 3; c++) run.centroid[c] += run.cloud[3 * i + c] / Math.max(1, cn);
    return run;
  });
  const layers = data.layers.map((layer, index) => {
    const parts = Int32Array.from(layer.parts);
    const local = new Map();
    parts.forEach((global, at) => local.set(global, at));
    const planar = t3Unpack(data.planes && data.planes[layer.name], false);
    const dynamic = Int32Array.from((data.dynamic && data.dynamic[layer.name]) ?? []);
    const free = Int32Array.from((data.free && data.free[layer.name]) ?? []);
    const framed = t3Unpack(data.framed && data.framed[layer.name], true);
    const groupPairs = [];
    const raw = (data.groupPairs && data.groupPairs[layer.name]) ?? [];
    const members = new Map();
    for (const global of parts) {
      const g = runs[global].group;
      if (!members.has(g)) members.set(g, []);
      members.get(g).push(global);
    }
    for (let p = 0; p < raw.length; p += 6) groupPairs.push({ a: Int32Array.from(members.get(raw[p]) ?? []), b: Int32Array.from(members.get(raw[p + 1]) ?? []), frame: raw[p + 2], n: [raw[p + 3], raw[p + 4], raw[p + 5]], boxA: new Float64Array(4), boxB: new Float64Array(4) });
    const looseAt = new Uint8Array(parts.length);
    for (const global of free) looseAt[local.get(global)] = 1;
    return { index, name: layer.name, parts, local, rest: Int32Array.from(layer.rest ?? layer.parts), planar, framed, groupPairs, dynamic, free, looseAt, warm: new Map() };
  });
  return { P, data, unit, groups: data.groups, runs, layers };
}

function t3Frame(run) {
  const count = run.roles.length;
  return { d: new Array(count).fill(""), q: new Int32Array(count), w: new Float64Array(count).fill(-1), t: new Array(count).fill(""), o: new Float64Array(count).fill(1), box: new Float64Array(4), hull: new Float64Array(64), hullN: 0, cx: new Float64Array(count), cy: new Float64Array(count), quads: null, quadN: 0 };
}

const t3Buf = { a: new Float64Array(1024), b: new Float64Array(1024), c: new Float64Array(1024) };

function t3Scratch(name, size) {
  if (t3Buf[name].length < size) t3Buf[name] = new Float64Array(size * 2);
  return t3Buf[name];
}

function t3SetHull(f, buf, count) {
  const hull = t3Grow(f, "hull", 2 * count, Float64Array);
  for (let i = 0; i < 2 * count; i++) hull[i] = buf[i];
  f.hullN = count;
}

function t3Faded(f, run, prefix, lines, count) {
  if (!f.fade) f.fade = {};
  const detail = t3State.detail;
  if (detail < 1) for (let i = 0; i < count; i++) lines.alpha[i] *= detail;
  if (detail < 0.01) count = 0;
  f.fade[prefix] = { key: lines.key.slice(0, count), alpha: lines.alpha.slice(0, count) };
  const slots = run.slots[prefix === "v" ? "bevel" : "ribs"] ?? 6;
  const levels = run._levels ?? (run._levels = new Array(T3_LEVELS + 1));
  for (let l = 0; l <= T3_LEVELS; l++) levels[l] = "";
  for (let i = 0; i < count; i++) {
    const alpha = lines.alpha[i];
    const level = Math.min(T3_LEVELS, Math.round(alpha * T3_LEVELS));
    if (level <= 0) continue;
    levels[level] += lines.d[i];
  }
  const present = [];
  for (let l = T3_LEVELS; l >= 1; l--) if (levels[l]) present.push([l, levels[l]]);
  while (present.length > slots) {
    let best = 1;
    let gap = Infinity;
    for (let i = 1; i < present.length; i++) {
      const g = present[i - 1][0] - present[i][0];
      if (g < gap) {
        gap = g;
        best = i;
      }
    }
    const [a, da] = present[best - 1];
    const [b, db] = present[best];
    present.splice(best - 1, 2, [(a + b) / 2, da + db]);
  }
  for (let j = 0; j < slots; j++) {
    const role = run.role[`${prefix}${j}`];
    if (role === undefined) continue;
    if (j < present.length) {
      f.d[role] = present[j][1];
      f.o[role] = present[j][0] / T3_LEVELS;
    } else {
      f.d[role] = "";
      f.o[role] = 0;
    }
  }
  return present.length;
}

const t3Lines = { d: [], alpha: [], key: [] };
const t3None = { d: [], alpha: [], key: [] };

function t3EmitPrism(run, cam, f) {
  const n = run.n;
  const m = cam.m;
  const ox = cam.ox;
  const oy = cam.oy;
  const V = cam.V;
  const L = cam.L;
  const lo = run.lo;
  const hi = run.hi;
  const N3 = run.N3;
  const bot = t3Scratch("a", 2 * n);
  const top = t3Scratch("b", 2 * n);
  for (let i = 0; i < n; i++) {
    const a = 3 * i;
    bot[2 * i] = ox + m[0] * lo[a] + m[1] * lo[a + 1] + m[2] * lo[a + 2];
    bot[2 * i + 1] = oy + m[3] * lo[a] + m[4] * lo[a + 1] + m[5] * lo[a + 2];
    top[2 * i] = ox + m[0] * hi[a] + m[1] * hi[a + 1] + m[2] * hi[a + 2];
    top[2 * i + 1] = oy + m[3] * hi[a] + m[4] * hi[a + 1] + m[5] * hi[a + 2];
  }
  const F = run.F;
  const aV = F.a[0] * V[0] + F.a[1] * V[1] + F.a[2] * V[2];
  const near = aV >= 0 ? top : bot;
  const far = aV >= 0 ? bot : top;
  const facing = run._facing ?? (run._facing = new Float64Array(n));
  for (let i = 0; i < n; i++) facing[i] = N3[3 * i] * V[0] + N3[3 * i + 1] * V[1] + N3[3 * i + 2] * V[2];
  const on = (i) => facing[((i % n) + n) % n] > 1e-9;
  let i0 = -1;
  for (let i = 0; i < n; i++)
    if (on(i) && !on(i - 1)) {
      i0 = i;
      break;
    }
  const sil = t3Scratch("c", 4 * n + 8);
  const silAt = run._silAt ?? (run._silAt = new Int32Array(2 * n + 4));
  let count = 0;
  let i1 = i0;
  const nearSet = aV >= 0 ? hi : lo;
  const farSet = aV >= 0 ? lo : hi;
  if (i0 < 0) {
    for (let i = 0; i < n; i++) {
      sil[2 * count] = near[2 * i];
      sil[2 * count + 1] = near[2 * i + 1];
      silAt[count] = i;
      count++;
    }
  } else {
    while (on(i1)) i1++;
    for (let j = i0; j <= i1; j++) {
      const i = j % n;
      sil[2 * count] = far[2 * i];
      sil[2 * count + 1] = far[2 * i + 1];
      silAt[count] = -1 - i;
      count++;
    }
    for (let j = i1; j <= i0 + n; j++) {
      const i = j % n;
      sil[2 * count] = near[2 * i];
      sil[2 * count + 1] = near[2 * i + 1];
      silAt[count] = i;
      count++;
    }
    if (count > 2 && Math.abs(sil[0] - sil[2 * count - 2]) < 1e-9 && Math.abs(sil[1] - sil[2 * count - 1]) < 1e-9) count--;
  }
  const outline = t3Path(sil, count, true);
  f.d[run.role.fill] = outline;
  if (run.cut) {
    const inPlane = (index) => {
      const at = silAt[index];
      const set = at < 0 ? farSet : nearSet;
      const v = at < 0 ? -1 - at : at;
      return run.cut.some((cut) => Math.abs(cut.n[0] * set[3 * v] + cut.n[1] * set[3 * v + 1] + cut.n[2] * set[3 * v + 2] - cut.d) < 1e-3);
    };
    const flags = new Uint8Array(count);
    const onSame = (i, j) => {
      const at = (index) => {
        const a = silAt[index];
        const set = a < 0 ? farSet : nearSet;
        const v = a < 0 ? -1 - a : a;
        return [set[3 * v], set[3 * v + 1], set[3 * v + 2]];
      };
      const p = at(i);
      const q = at(j);
      return run.cut.some((cut) => Math.abs(cut.n[0] * p[0] + cut.n[1] * p[1] + cut.n[2] * p[2] - cut.d) < 1e-3 && Math.abs(cut.n[0] * q[0] + cut.n[1] * q[1] + cut.n[2] * q[2] - cut.d) < 1e-3);
    };
    for (let j = 0; j < count; j++) flags[j] = inPlane(j) && onSame(j, (j + 1) % count) ? 1 : 0;
    f.d[run.role.edge] = t3Broken(sil, count, flags);
  } else f.d[run.role.edge] = outline;
  t3Box(sil, count, f.box);
  t3SetHull(f, sil, count);
  const quad = new Float64Array(8);
  for (let i = 0; i < n; i++) {
    const role = run.role[`f${i}`];
    if (!(facing[i] > 1e-6)) {
      f.d[role] = "";
      continue;
    }
    const j = (i + 1) % n;
    quad[0] = top[2 * i];
    quad[1] = top[2 * i + 1];
    quad[2] = top[2 * j];
    quad[3] = top[2 * j + 1];
    quad[4] = bot[2 * j];
    quad[5] = bot[2 * j + 1];
    quad[6] = bot[2 * i];
    quad[7] = bot[2 * i + 1];
    f.d[role] = t3Path(quad, 4, true);
    let score;
    if (run.up) {
      score = (run.nx[i] * cam.lx + run.ny[i] * cam.ly + 1) / 2;
      f.q[role] = t3Level(score, T3_EB);
    } else {
      score = N3[3 * i] * L[0] + N3[3 * i + 1] * L[1] + N3[3 * i + 2] * L[2];
      f.q[role] = t3Level(score, T3_LB);
    }
    const e1 = Math.hypot(quad[2] - quad[0], quad[3] - quad[1]);
    const e2 = Math.hypot(quad[6] - quad[0], quad[7] - quad[1]);
    const area = Math.abs((quad[2] - quad[0]) * (quad[7] - quad[1]) - (quad[3] - quad[1]) * (quad[6] - quad[0]));
    const wide = area / (Math.max(e1, e2) || 1);
    f.w[role] = wide < 0.4 ? Math.round(wide * 100) / 100 : -1;
  }
  if (run.up) {
    const z = run.s1;
    f.t[run.role.top] = t3Arena.on ? t3Matrix(f, run.role.top, m[0], m[3], m[1], m[4], ox + m[2] * z, oy + m[5] * z) : `matrix(${t3Fix(m[0], 1e5)} ${t3Fix(m[3], 1e5)} ${t3Fix(m[1], 1e5)} ${t3Fix(m[4], 1e5)} ${t3Fix(ox + m[2] * z, 100)} ${t3Fix(oy + m[5] * z, 100)})`;
  } else {
    for (let side = 0; side < 2; side++) {
      const role = run.role[`f${n + side}`];
      const sign = side ? 1 : -1;
      const face = sign * aV;
      if (!(face > 1e-6)) {
        f.d[role] = "";
        continue;
      }
      const ring = side ? top : bot;
      f.d[role] = t3Path(ring, n, true);
      f.q[role] = t3Level(sign * (F.a[0] * L[0] + F.a[1] * L[1] + F.a[2] * L[2]), T3_LB);
      let minor = Infinity;
      let x0 = Infinity;
      let x1 = -Infinity;
      for (let i = 0; i < n; i++) {
        const j = (i + 1) % n;
        const ex = ring[2 * j] - ring[2 * i];
        const ey = ring[2 * j + 1] - ring[2 * i + 1];
        const l = Math.hypot(ex, ey);
        if (l < 1e-9) continue;
        let lo2 = Infinity;
        let hi2 = -Infinity;
        for (let q = 0; q < n; q++) {
          const v = ((ring[2 * q] - ring[2 * i]) * ey - (ring[2 * q + 1] - ring[2 * i + 1]) * ex) / l;
          if (v < lo2) lo2 = v;
          if (v > hi2) hi2 = v;
        }
        minor = Math.min(minor, hi2 - lo2);
        x0 = Math.min(x0, lo2);
        x1 = Math.max(x1, hi2);
      }
      f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    }
  }
  if (run.role.crease !== undefined) {
    let crease = "";
    if (run.up) {
      if (i0 >= 0) {
        const buf = t3Scratch("a", 0);
        let c = 0;
        const rim = t3Scratch("c", 2 * n + 4);
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          rim[2 * c] = top[2 * i];
          rim[2 * c + 1] = top[2 * i + 1];
          c++;
        }
        crease = t3Path(rim, c, false);
        void buf;
      }
    } else {
      const capOn = [-aV > 1e-6, aV > 1e-6];
      const seg = new Float64Array(4);
      for (let i = 0; i < n; i++) {
        const p = (i - 1 + n) % n;
        if (run.sharp[i] && facing[i] > 1e-6 && facing[p] > 1e-6) {
          seg[0] = bot[2 * i];
          seg[1] = bot[2 * i + 1];
          seg[2] = top[2 * i];
          seg[3] = top[2 * i + 1];
          crease += t3Path(seg, 2, false);
        }
      }
      for (let side = 0; side < 2; side++) {
        if (!capOn[side] || i0 < 0) continue;
        const ring = side ? top : bot;
        const rim = t3Scratch("c", 2 * n + 4);
        let c = 0;
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          rim[2 * c] = ring[2 * i];
          rim[2 * c + 1] = ring[2 * i + 1];
          c++;
        }
        crease += t3Path(rim, c, false);
      }
    }
    f.d[run.role.crease] = crease;
  }
  if (run.inset && run.role.v0 !== undefined && t3State.detail < 0.01) f.levels = t3Faded(f, run, "v", t3None, 0);
  else if (run.inset && run.role.v0 !== undefined) {
    const lines = t3Lines;
    lines.d.length = 0;
    lines.alpha.length = 0;
  lines.key.length = 0;
    lines.key.length = 0;
    const capSide = aV >= 0 ? 1 : 0;
    const s = capSide ? run.s1 : run.s0;
    const capAlpha = run.up ? 1 : t3Step(0.02, 0.2, Math.abs(aV));
    const ins = t3Scratch("c", 2 * n);
    for (let i = 0; i < n; i++) {
      const x = run.inset[2 * i];
      const y = run.inset[2 * i + 1];
      const px = F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y;
      const py = F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y;
      const pz = F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y;
      ins[2 * i] = ox + m[0] * px + m[1] * py + m[2] * pz;
      ins[2 * i + 1] = oy + m[3] * px + m[4] * py + m[5] * pz;
    }
    const rim = capSide ? top : bot;
    const seg = new Float64Array(4);
    for (let i = 0; i < n; i++) {
      const p = (i - 1 + n) % n;
      const ai = capAlpha * t3Step(0, 0.14, facing[i]);
      const ap = capAlpha * t3Step(0, 0.14, facing[p]);
      if (ai > 0.01) {
        const j = (i + 1) % n;
        seg[0] = ins[2 * i];
        seg[1] = ins[2 * i + 1];
        seg[2] = ins[2 * j];
        seg[3] = ins[2 * j + 1];
        lines.d.push(t3Path(seg, 2, false));
        lines.alpha.push(ai);
        lines.key.push(2 * i);
      }
      const corner = ai * (1 - ap) + ap * (1 - ai);
      if (corner > 0.01) {
        seg[0] = ins[2 * i];
        seg[1] = ins[2 * i + 1];
        seg[2] = rim[2 * i];
        seg[3] = rim[2 * i + 1];
        lines.d.push(t3Path(seg, 2, false));
        lines.alpha.push(corner);
        lines.key.push(2 * i + 1);
      }
    }
    f.levels = t3Faded(f, run, "v", lines, lines.d.length);
  }
  if (run.seams && run.role.inner !== undefined) {
    let inner = "";
    if (i0 >= 0) {
      const rim = t3Scratch("c", 2 * n + 4);
      for (const s of run.seams) {
        let c = 0;
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          const x = run.ring[2 * i];
          const y = run.ring[2 * i + 1];
          const px = F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y;
          const py = F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y;
          const pz = F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y;
          rim[2 * c] = ox + m[0] * px + m[1] * py + m[2] * pz;
          rim[2 * c + 1] = oy + m[3] * px + m[4] * py + m[5] * pz;
          c++;
        }
        inner += t3Path(rim, c, false);
      }
    }
    f.d[run.role.inner] = inner;
  }
}

function t3Broken(buf, count, skip) {
  let start = -1;
  for (let j = 0; j < count; j++)
    if (skip[(j - 1 + count) % count] && !skip[j]) {
      start = j;
      break;
    }
  if (start < 0) return skip[0] ? "" : t3Path(buf, count, true);
  let out = "";
  let run = [];
  for (let step = 0; step < count; step++) {
    const j = (start + step) % count;
    if (skip[j]) {
      if (run.length) {
        run.push((j + 0) % count);
        out += t3PathOf(run.map((i) => [buf[2 * i], buf[2 * i + 1]]), false);
        run = [];
      }
      continue;
    }
    run.push(j);
  }
  if (run.length) {
    run.push((start + count) % count);
    out += t3PathOf(run.map((i) => [buf[2 * i], buf[2 * i + 1]]), false);
  }
  return out;
}

function t3EmitPlanes(run, cam, f, sx = 0, sy = 0) {
  const m = cam.m;
  const V = cam.V;
  for (let j = 0; j < run.planes.length; j++) {
    const plane = run.planes[j];
    const role = run.role[`plane${j}`];
    const o = plane.o;
    const X = cam.ox - sx + m[0] * o[0] + m[1] * o[1] + m[2] * o[2];
    const Y = cam.oy - sy + m[3] * o[0] + m[4] * o[1] + m[5] * o[2];
    const facing = plane.n[0] * V[0] + plane.n[1] * V[1] + plane.n[2] * V[2];
    const alpha = Math.round(t3Step(plane.fade[0], plane.fade[1], facing) * 100) / 100;
    f.o[role] = alpha;
    if (plane.billboard) {
      f.t[role] = t3Arena.on ? t3Matrix(f, role, 1, 0, 0, 1, X, Y) : `translate(${t3Num(X)} ${t3Num(Y)})`;
      continue;
    }
    const u = plane.u;
    const v = plane.v;
    const a = m[0] * u[0] + m[1] * u[1] + m[2] * u[2];
    const b = m[3] * u[0] + m[4] * u[1] + m[5] * u[2];
    const c = m[0] * v[0] + m[1] * v[1] + m[2] * v[2];
    const d = m[3] * v[0] + m[4] * v[1] + m[5] * v[2];
    f.t[role] = t3Arena.on ? t3Matrix(f, role, a, b, c, d, X, Y) : `matrix(${t3Fix(a, 1e5)} ${t3Fix(b, 1e5)} ${t3Fix(c, 1e5)} ${t3Fix(d, 1e5)} ${t3Fix(X, 100)} ${t3Fix(Y, 100)})`;
  }
}

function t3EmitDots(run, cam, f, sx = 0, sy = 0) {
  const m = cam.m;
  const V = cam.V;
  for (let j = 0; j < run.dots.length; j++) {
    const dot = run.dots[j];
    const role = run.role[`c${j}`];
    const p = dot.p;
    f.cx[role] = cam.ox - sx + m[0] * p[0] + m[1] * p[1] + m[2] * p[2];
    f.cy[role] = cam.oy - sy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2];
    const alpha = (dot.n ? t3Step(dot.fade[0], dot.fade[1], dot.n[0] * V[0] + dot.n[1] * V[1] + dot.n[2] * V[2]) : 1) * t3State.detail;
    f.o[role] = alpha < 0.005 ? 0 : Math.round(alpha * 100) / 100;
  }
}

function t3EmitFixed(run, cam, f) {
  const a = run.anchor;
  const m = cam.m;
  const X = cam.ox + m[0] * a[0] + m[1] * a[1] + m[2] * a[2];
  const Y = cam.oy + m[3] * a[0] + m[4] * a[1] + m[5] * a[2];
  const dx = X - run.home[0];
  const dy = Y - run.home[1];
  const rx = Math.round(dx * 100) / 100;
  const ry = Math.round(dy * 100) / 100;
  f.t[run.role.move] = t3Arena.on ? t3Matrix(f, run.role.move, 1, 0, 0, 1, rx, ry) : rx || ry ? `translate(${t3Num(rx)} ${t3Num(ry)})` : "";
  f.shiftX = rx;
  f.shiftY = ry;
  const b = run.box0;
  f.box[0] = b[0] + rx;
  f.box[1] = b[1] + ry;
  f.box[2] = b[2] + rx;
  f.box[3] = b[3] + ry;
  const count = run.hull0.length / 2;
  const hull = t3Grow(f, "hull", 2 * count, Float64Array);
  for (let i = 0; i < count; i++) {
    hull[2 * i] = run.hull0[2 * i] + rx;
    hull[2 * i + 1] = run.hull0[2 * i + 1] + ry;
  }
  f.hullN = count;
  if (run.ribs && run.F) t3EmitRibs(run, cam, f, run.F, run.ribs, rx, ry);
  t3EmitPlanes(run, cam, f, rx, ry);
  t3EmitDots(run, cam, f, rx, ry);
}

function t3EmitRibs(run, cam, f, F, ribs, sx, sy) {
  if (t3State.detail < 0.01) {
    f.levels = t3Faded(f, run, "r", t3None, 0);
    return;
  }
  const m = cam.m;
  const V = cam.V;
  const lines = t3Lines;
  lines.d.length = 0;
  lines.alpha.length = 0;
  lines.key.length = 0;
  const { s0, s1, count, phase = 0, twist = 0, fade = [0.1, 0.55] } = ribs;
  const r0 = ribs.r0 ?? ribs.r;
  const r1 = ribs.r1 ?? r0;
  const slope = (r1 - r0) / (s1 - s0 || 1);
  const norm = Math.hypot(1, slope);
  const aV = F.a[0] * V[0] + F.a[1] * V[1] + F.a[2] * V[2];
  const uV = F.u[0] * V[0] + F.u[1] * V[1] + F.u[2] * V[2];
  const vV = F.v[0] * V[0] + F.v[1] * V[1] + F.v[2] * V[2];
  const seg = new Float64Array(4);
  const at = (s, r, t, out, index) => {
    const c = Math.cos(t);
    const n = Math.sin(t);
    const px = F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]);
    const py = F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]);
    const pz = F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]);
    out[index] = cam.ox - sx + m[0] * px + m[1] * py + m[2] * pz;
    out[index + 1] = cam.oy - sy + m[3] * px + m[4] * py + m[5] * pz;
  };
  for (let i = 0; i < count; i++) {
    const t = phase + (i / count) * T3_TAU;
    const tm = t + twist / 2;
    const facing = (Math.cos(tm) * uV + Math.sin(tm) * vV - slope * aV) / norm;
    const alpha = t3Step(fade[0], fade[1], facing);
    if (alpha * T3_LEVELS < 0.5) continue;
    at(s0, r0, t, seg, 0);
    at(s1, r1, t + twist, seg, 2);
    lines.d.push(t3Path(seg, 2, false));
    lines.alpha.push(alpha);
    lines.key.push(i);
  }
  f.levels = t3Faded(f, run, "r", lines, lines.d.length);
}


function t3Geo(F, cam) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const lin = (w) => [m[0] * w[0] + m[1] * w[1] + m[2] * w[2], m[3] * w[0] + m[4] * w[1] + m[5] * w[2]];
  const O = [cam.ox + m[0] * F.o[0] + m[1] * F.o[1] + m[2] * F.o[2], cam.oy + m[3] * F.o[0] + m[4] * F.o[1] + m[5] * F.o[2]];
  const aV = dot(F.a, V);
  const uV = dot(F.u, V);
  const vV = dot(F.v, V);
  const aL = dot(F.a, L);
  const uL = dot(F.u, L);
  const vL = dot(F.v, L);
  return { O, SA: lin(F.a), SU: lin(F.u), SV: lin(F.v), aV, aL, view: { a: aV, flat: Math.hypot(uV, vV), phi: Math.atan2(vV, uV) }, light: { a: aL, flat: Math.hypot(uL, vL), phi: Math.atan2(vL, uL) } };
}

function t3At(geo, s, r, t, buf, index) {
  const c = Math.cos(t);
  const n = Math.sin(t);
  buf[index] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + n * geo.SV[0]);
  buf[index + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + n * geo.SV[1]);
}

function t3Arcs(nr, na, view, light) {
  const cuts = [];
  if (Math.abs(nr) > 1e-9) {
    const visibleCut = -(na * view.a) / (nr * view.flat);
    if (visibleCut > -1 && visibleCut < 1) {
      const d = Math.acos(visibleCut);
      cuts.push(view.phi - d, view.phi + d);
    }
    for (let b = 0; b < T3_LB.length; b++) {
      const c = (T3_LB[b] - na * light.a) / (nr * light.flat);
      if (c > -1 && c < 1) {
        const d = Math.acos(c);
        cuts.push(light.phi - d, light.phi + d);
      }
    }
  }
  for (let i = 0; i < cuts.length; i++) {
    const w = ((cuts[i] % T3_TAU) + T3_TAU) % T3_TAU;
    cuts[i] = Math.round(w * 1e9) / 1e9;
  }
  cuts.sort((a, b) => a - b);
  const sorted = [];
  for (let i = 0; i < cuts.length; i++) if (!sorted.length || cuts[i] !== sorted[sorted.length - 1]) sorted.push(cuts[i]);
  const label = (angle) => {
    const facing = nr * view.flat * Math.cos(angle - view.phi) + na * view.a;
    if (facing <= 1e-9) return -1;
    const score = nr * light.flat * Math.cos(angle - light.phi) + na * light.a;
    let tone = 0;
    while (tone < T3_LB.length && score >= T3_LB[tone]) tone++;
    return tone;
  };
  if (!sorted.length) {
    const tone = label(0);
    return tone < 0 ? [] : [{ tone, from: 0, to: T3_TAU, full: true }];
  }
  const merged = [];
  for (let index = 0; index < sorted.length; index++) {
    const from = sorted[index];
    const to = index + 1 < sorted.length ? sorted[index + 1] : sorted[0] + T3_TAU;
    const tone = label((from + to) / 2);
    const last = merged[merged.length - 1];
    if (last && last.tone === tone && Math.abs(last.to - from) < 1e-9) last.to = to;
    else merged.push({ tone, from, to });
  }
  if (merged.length > 1 && merged[0].tone === merged[merged.length - 1].tone && Math.abs(merged[merged.length - 1].to - T3_TAU - merged[0].from) < 1e-9) {
    const last = merged.pop();
    merged[0].from = last.from - T3_TAU;
  }
  const out = [];
  for (const arc of merged) if (arc.tone >= 0) out.push(arc);
  return out;
}

function t3Strip(geo, s0, r0, s1, r1, from, to, step) {
  const count = Math.max(1, Math.ceil((to - from) / step));
  const buf = t3Scratch("c", 4 * count + 8);
  for (let j = 0; j <= count; j++) {
    const t = from + ((to - from) * j) / count;
    t3At(geo, s0, r0, t, buf, 2 * j);
    t3At(geo, s1, r1, t, buf, 2 * (2 * count + 1 - j));
  }
  return t3Path(t3Oriented(buf, 2 * count + 2), 2 * count + 2, true);
}

function t3Oriented(buf, count) {
  let area = 0;
  for (let i = 0; i < count; i++) {
    const j = (i + 1) % count;
    area += buf[2 * i] * buf[2 * j + 1] - buf[2 * j] * buf[2 * i + 1];
  }
  if (area >= 0) return buf;
  for (let i = 0, j = count - 1; i < j; i++, j--) {
    const x = buf[2 * i];
    const y = buf[2 * i + 1];
    buf[2 * i] = buf[2 * j];
    buf[2 * i + 1] = buf[2 * j + 1];
    buf[2 * j] = x;
    buf[2 * j + 1] = y;
  }
  return buf;
}

function t3Hull(points) {
  const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  if (sorted.length < 3) return sorted;
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lower = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 1e-12) lower.pop();
    lower.push(p);
  }
  const upper = [];
  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 1e-12) upper.pop();
    upper.push(p);
  }
  return lower.slice(0, -1).concat(upper.slice(0, -1));
}

function t3PathOf(points, closed) {
  if (points.length < 2) return "";
  if (t3Arena.on) return t3Token((i) => points[i >> 1][i & 1], points.length, closed);
  let s = "M" + t3Num(points[0][0]) + " " + t3Num(points[0][1]);
  for (let i = 1; i < points.length; i++) s += "L" + t3Num(points[i][0]) + " " + t3Num(points[i][1]);
  return closed ? s + "Z" : s;
}

function t3Basis(w) {
  const helper = Math.abs(w[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
  const u = t3Unit([helper[1] * w[2] - helper[2] * w[1], helper[2] * w[0] - helper[0] * w[2], helper[0] * w[1] - helper[1] * w[0]]);
  return [u, [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]]];
}

function t3Meet(A, a, B, b) {
  const ab = A[0] * B[0] + A[1] * B[1] + A[2] * B[2];
  const det = 1 - ab * ab;
  if (det < 1e-12) return [];
  const alpha = (a - b * ab) / det;
  const beta = (b - a * ab) / det;
  const base = [A[0] * alpha + B[0] * beta, A[1] * alpha + B[1] * beta, A[2] * alpha + B[2] * beta];
  const rest = 1 - (base[0] * base[0] + base[1] * base[1] + base[2] * base[2]);
  if (rest < 0) return [];
  const cr = [A[1] * B[2] - A[2] * B[1], A[2] * B[0] - A[0] * B[2], A[0] * B[1] - A[1] * B[0]];
  const k = Math.sqrt(rest / det);
  return [
    [base[0] + cr[0] * k, base[1] + cr[1] * k, base[2] + cr[2] * k],
    [base[0] - cr[0] * k, base[1] - cr[1] * k, base[2] - cr[2] * k],
  ];
}

const t3Tables = new Map();
function t3Table(count) {
  let table = t3Tables.get(count);
  if (!table) {
    table = new Float64Array(2 * count);
    for (let j = 0; j < count; j++) {
      table[2 * j] = Math.cos((j / count) * T3_TAU);
      table[2 * j + 1] = Math.sin((j / count) * T3_TAU);
    }
    t3Tables.set(count, table);
  }
  return table;
}

function t3Dome(cam, C, e, r, count, sheets) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const Sx = cam.ox + m[0] * C[0] + m[1] * C[1] + m[2] * C[2];
  const Sy = cam.oy + m[3] * C[0] + m[4] * C[1] + m[5] * C[2];
  const flat = (n) => [Sx + r * (m[0] * n[0] + m[1] * n[1] + m[2] * n[2]), Sy + r * (m[3] * n[0] + m[4] * n[1] + m[5] * n[2])];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const table = t3Table(count);
  const circle = (W, value, keep) => {
    const [U, Q] = t3Basis(W);
    const rad = Math.sqrt(Math.max(0, 1 - value * value));
    const out = [];
    for (let j = 0; j < count; j++) {
      const cs = table[2 * j] * rad;
      const sn = table[2 * j + 1] * rad;
      const n = [W[0] * value + U[0] * cs + Q[0] * sn, W[1] * value + U[1] * cs + Q[1] * sn, W[2] * value + U[2] * cs + Q[2] * sn];
      if (keep(n)) out.push(n);
    }
    return out;
  };
  const rim = circle(V, 0, (n) => dot(n, e) >= -1e-9).map((n) => [flat(n), dot(n, L)]);
  const base = circle(e, 0, (n) => dot(n, V) >= -1e-9).map((n) => [flat(n), dot(n, L)]);
  const corners = t3Meet(V, 0, e, 0).map((n) => [flat(n), dot(n, L)]);
  for (let tone = 0; tone < 5; tone++) {
    const tau = tone === 0 ? -Infinity : T3_LB[tone - 1];
    const candidates = [];
    for (const list of [rim, base, corners]) for (const [p, lit] of list) if (lit >= tau - 1e-9) candidates.push(p);
    if (tone > 0) {
      for (const n of circle(L, tau, (n) => dot(n, V) >= -1e-9 && dot(n, e) >= -1e-9)) candidates.push(flat(n));
      for (const n of t3Meet(L, tau, V, 0)) if (dot(n, e) >= -1e-9) candidates.push(flat(n));
      for (const n of t3Meet(L, tau, e, 0)) if (dot(n, V) >= -1e-9) candidates.push(flat(n));
    }
    if (candidates.length < 3) continue;
    const region = t3Hull(candidates);
    if (region.length >= 3) sheets[tone] += t3PathOf(region, true);
  }
}

function t3BoxOf(pts, box) {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const [x, y] of pts) {
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  box[0] = x0;
  box[1] = y0;
  box[2] = x1;
  box[3] = y1;
}

function t3Thin(points, most) {
  if (points.length <= most) return points;
  const n = points.length;
  const picks = [];
  for (let i = 0; i < most; i++) picks.push(Math.floor((i * n) / most));
  const thin = picks.map((i) => points[i]);
  let err = 0;
  for (let j = 0; j < most; j++) {
    const a = thin[j];
    const b = thin[(j + 1) % most];
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const l = Math.hypot(dx, dy) || 1;
    const end = j + 1 < most ? picks[j + 1] : picks[0] + n;
    for (let i = picks[j] + 1; i < end; i++) {
      const p = points[i % n];
      const out = ((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / l;
      if (Math.abs(out) > err) err = Math.abs(out);
    }
  }
  return t3Grown(thin, err);
}

function t3Grown(poly, by) {
  if (!(by > 0)) return poly;
  let area = 0;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = area >= 0 ? 1 : -1;
  const n = poly.length;
  const normals = poly.map((a, i) => {
    const b = poly[(i + 1) % n];
    const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    return [(sign * (b[1] - a[1])) / l, (-sign * (b[0] - a[0])) / l];
  });
  return poly.map((p, i) => {
    const na = normals[(i - 1 + n) % n];
    const nb = normals[i];
    const mx = na[0] + nb[0];
    const my = na[1] + nb[1];
    const dd = (mx * mx + my * my) / 2 || 1;
    return [p[0] + (mx * by) / dd, p[1] + (my * by) / dd];
  });
}

function t3HullOut(f, points, most = 32) {
  const thin = t3Thin(points, most);
  const hull = t3Grow(f, "hull", 2 * thin.length, Float64Array);
  for (let i = 0; i < thin.length; i++) {
    hull[2 * i] = thin[i][0];
    hull[2 * i + 1] = thin[i][1];
  }
  f.hullN = thin.length;
}

function t3SupportHull(geo, cam, components, dirs) {
  const table = t3Table(dirs);
  const pts = [];
  const { O, SA, SU, SV } = geo;
  for (let j = 0; j < dirs; j++) {
    const dx = table[2 * j];
    const dy = table[2 * j + 1];
    const pa = SA[0] * dx + SA[1] * dy;
    const pu = SU[0] * dx + SU[1] * dy;
    const pv = SV[0] * dx + SV[1] * dy;
    const w = Math.hypot(pu, pv);
    let best = -Infinity;
    let px = 0;
    let py = 0;
    for (let c = 0; c < components.length; c += 3) {
      const kind = components[c];
      const s = components[c + 1];
      const r = components[c + 2];
      if (kind === 0) {
        const value = s * pa + r * w;
        if (value > best) {
          best = value;
          const cs = w > 1e-12 ? pu / w : 1;
          const sn = w > 1e-12 ? pv / w : 0;
          px = O[0] + s * SA[0] + r * (cs * SU[0] + sn * SV[0]);
          py = O[1] + s * SA[1] + r * (cs * SU[1] + sn * SV[1]);
        }
      } else {
        const rk = r * cam.k;
        const value = s * pa + rk;
        if (value > best) {
          best = value;
          px = O[0] + s * SA[0] + rk * dx;
          py = O[1] + s * SA[1] + rk * dy;
        }
      }
    }
    const last = pts[pts.length - 1];
    if (!last || Math.abs(last[0] - px) + Math.abs(last[1] - py) > 0.02) pts.push([px, py]);
  }
  if (pts.length > 2) {
    const a = pts[0];
    const b = pts[pts.length - 1];
    if (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) <= 0.02) pts.pop();
  }
  return pts;
}

function t3ArcPoints(geo, s, r, from, to, k, out, cut = false) {
  const count = Math.max(2, Math.ceil((Math.abs(to - from) * 48) / T3_TAU), Math.ceil((Math.abs(to - from) * Math.max(r, 1e-6) * k) / 1.5));
  for (let j = 0; j <= count; j++) {
    const t = from + ((to - from) * j) / count;
    const c = Math.cos(t);
    const n = Math.sin(t);
    const p = [geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + n * geo.SV[0]), geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + n * geo.SV[1])];
    if (cut) p.cut = true;
    out.push(p);
  }
}

function t3RoundOutline(run, geo, cam) {
  const k = cam.k;
  const { s0, s1, r0, r1, ends } = run;
  const domes = (ends[0] === "dome" ? 1 : 0) + (ends[1] === "dome" ? 1 : 0);
  if (domes === 2) {
    const c0 = [geo.O[0] + s0 * geo.SA[0], geo.O[1] + s0 * geo.SA[1]];
    const c1 = [geo.O[0] + s1 * geo.SA[0], geo.O[1] + s1 * geo.SA[1]];
    const rk = r0 * k;
    const dx = c1[0] - c0[0];
    const dy = c1[1] - c0[1];
    const l = Math.hypot(dx, dy);
    const count = Math.max(24, Math.ceil((Math.PI * rk) / 1.5));
    const out = [];
    if (l < 1e-6) {
      for (let j = 0; j < 2 * count; j++) {
        const t = (j / (2 * count)) * T3_TAU;
        out.push([c1[0] + rk * Math.cos(t), c1[1] + rk * Math.sin(t)]);
      }
      return out;
    }
    const beta = Math.atan2(dy, dx);
    for (let j = 0; j <= count; j++) {
      const t = beta - Math.PI / 2 + (Math.PI * j) / count;
      out.push([c1[0] + rk * Math.cos(t), c1[1] + rk * Math.sin(t)]);
    }
    for (let j = 0; j <= count; j++) {
      const t = beta + Math.PI / 2 + (Math.PI * j) / count;
      out.push([c0[0] + rk * Math.cos(t), c0[1] + rk * Math.sin(t)]);
    }
    return out;
  }
  if (domes === 1) {
    const pts = [];
    for (const [end, s, r] of [[0, s0, r0], [1, s1, r1]]) {
      t3ArcPoints(geo, s, r, 0, T3_TAU, k, pts);
      if (ends[end] !== "dome") continue;
      const sign = end ? 1 : -1;
      const c = [geo.O[0] + s * geo.SA[0], geo.O[1] + s * geo.SA[1]];
      const rk = r * k;
      const beta = Math.atan2(sign * geo.SA[1], sign * geo.SA[0]);
      const count = Math.max(24, Math.ceil((Math.PI * rk) / 1.5));
      for (let j = 0; j <= count; j++) {
        const t = beta - Math.PI / 2 + (Math.PI * j) / count;
        pts.push([c[0] + rk * Math.cos(t), c[1] + rk * Math.sin(t)]);
      }
    }
    return t3Hull(pts);
  }
  const view = geo.view;
  const out = [];
  let c;
  if (view.flat < 1e-9) c = run.na * view.a > 0 ? -2 : 2;
  else c = -(run.na * view.a) / (run.nr * view.flat);
  if (c <= -1 || c >= 1) {
    const side = c <= -1;
    const capFirst = -geo.aV > 0;
    const keepFirst = side ? !capFirst : capFirst;
    const s = keepFirst ? s0 : s1;
    const r = keepFirst ? r0 : r1;
    t3ArcPoints(geo, s, r, 0, T3_TAU, k, out, run.cutS !== null && Math.abs(run.cutS - s) < 1e-6);
    out.pop();
    return out;
  }
  const d = Math.acos(c);
  const lo = view.phi - d;
  const hi = view.phi + d;
  const cut0 = run.cutS !== null && Math.abs(run.cutS - s0) < 1e-6;
  const cut1 = run.cutS !== null && Math.abs(run.cutS - s1) < 1e-6;
  if (-geo.aV > 0) t3ArcPoints(geo, s0, r0, hi, lo + T3_TAU, k, out, cut0);
  else t3ArcPoints(geo, s0, r0, lo, hi, k, out, cut0);
  const tail = [];
  if (geo.aV > 0) t3ArcPoints(geo, s1, r1, hi, lo + T3_TAU, k, tail, cut1);
  else t3ArcPoints(geo, s1, r1, lo, hi, k, tail, cut1);
  const first = out[out.length - 1];
  const a = tail[0];
  const b = tail[tail.length - 1];
  if (Math.hypot(a[0] - first[0], a[1] - first[1]) > Math.hypot(b[0] - first[0], b[1] - first[1])) tail.reverse();
  for (const p of tail) out.push(p);
  return out;
}

function t3LatheMid(a, b) {
  const h = b.s - a.s;
  const ga = -a.na / a.nr;
  const gb = -b.na / b.nr;
  const nr = a.nr + b.nr;
  const na = a.na + b.na;
  const l = Math.hypot(nr, na) || 1;
  return { s: (a.s + b.s) / 2, r: (a.r + b.r) / 2 + (h * (ga - gb)) / 8, nr: nr / l, na: na / l };
}

function t3LatheOutline(run, geo, cam) {
  const k = cam.k;
  const view = geo.view;
  const lv = run.levels;
  const pts = [];
  const cut = (L) => (view.flat < 1e-9 ? Infinity : -(L.na * view.a) / (L.nr * view.flat));
  const inside = (L) => L.r <= 1e-9 || Math.abs(cut(L)) < 1;
  const sides = (L) => {
    if (L.r <= 1e-9) {
      const p = [geo.O[0] + L.s * geo.SA[0], geo.O[1] + L.s * geo.SA[1]];
      return [p, p];
    }
    const c = Math.max(-1, Math.min(1, cut(L)));
    const d = Math.acos(c);
    return [view.phi - d, view.phi + d].map((t) => {
      const cs = Math.cos(t);
      const sn = Math.sin(t);
      return [geo.O[0] + L.s * geo.SA[0] + L.r * (cs * geo.SU[0] + sn * geo.SV[0]), geo.O[1] + L.s * geo.SA[1] + L.r * (cs * geo.SU[1] + sn * geo.SV[1])];
    });
  };
  const span = (a, b) => {
    const pa = sides(a);
    const pb = sides(b);
    return Math.max(Math.hypot(pa[0][0] - pb[0][0], pa[0][1] - pb[0][1]), Math.hypot(pa[1][0] - pb[1][0], pa[1][1] - pb[1][1]));
  };
  const refine = (a, b, depth) => {
    if (depth >= 10 || span(a, b) < 1.2) return;
    const mid = t3LatheMid(a, b);
    refine(a, mid, depth + 1);
    pts.push(...sides(mid));
    refine(mid, b, depth + 1);
  };
  let previous = null;
  for (let i = 0; i < lv.length; i += 4) {
    const L = { s: lv[i], r: lv[i + 1], nr: lv[i + 2], na: lv[i + 3] };
    if (inside(L)) pts.push(...sides(L));
    if (previous) {
      const pin = inside(previous);
      const lin = inside(L);
      if (pin && lin) refine(previous, L, 0);
      else if (pin !== lin) {
        let a = pin ? previous : L;
        let b = pin ? L : previous;
        const start = a;
        for (let it = 0; it < 14; it++) {
          const mid = t3LatheMid(a.s < b.s ? a : b, a.s < b.s ? b : a);
          if (inside(mid)) a = mid;
          else b = mid;
        }
        pts.push(...sides(a));
        refine(start.s < a.s ? start : a, start.s < a.s ? a : start, 0);
      }
    }
    previous = L;
  }
  for (const i of [0, lv.length - 4]) if (lv[i + 1] > 1e-9) t3ArcPoints(geo, lv[i], lv[i + 1], 0, T3_TAU, k, pts);
  return t3Hull(pts);
}

function t3SeamArc(geo, s, r, slope, k, trim = 0.3) {
  const view = geo.view;
  const norm = Math.hypot(1, slope);
  let lo;
  let hi;
  if (view.flat < 1e-9) {
    if (!((-slope * view.a) / norm > 0)) return "";
    lo = view.phi;
    hi = view.phi + T3_TAU;
  } else {
    const cc = (slope * view.a) / view.flat;
    if (cc >= 1) return "";
    if (cc <= -1) {
      lo = view.phi;
      hi = view.phi + T3_TAU;
    } else {
      const d = Math.acos(cc);
      lo = view.phi - d;
      hi = view.phi + d;
    }
  }
  const full = hi - lo >= T3_TAU - 1e-9;
  const buf = t3Scratch("c", 2 * 520);
  if (!full) {
    const span = hi - lo;
    const cutAt = (from, sign) => {
      t3At(geo, s, r, from, buf, 0);
      const px = buf[0];
      const py = buf[1];
      let a = 0;
      let b = span * 0.25;
      t3At(geo, s, r, from + sign * b, buf, 0);
      if (Math.hypot(buf[0] - px, buf[1] - py) <= trim) return b;
      for (let it = 0; it < 18; it++) {
        const mid = (a + b) / 2;
        t3At(geo, s, r, from + sign * mid, buf, 0);
        if (Math.hypot(buf[0] - px, buf[1] - py) < trim) a = mid;
        else b = mid;
      }
      return (a + b) / 2;
    };
    const d0 = cutAt(lo, 1);
    const d1 = cutAt(hi, -1);
    lo += d0;
    hi -= d1;
  }
  const count = Math.min(512, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
  for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, buf, 2 * j);
  return t3Path(buf, full ? count : count + 1, full);
}

function t3RibLines(geo, F, cam, ribs, sx, sy) {
  const lines = t3Lines;
  lines.d.length = 0;
  lines.alpha.length = 0;
  lines.key.length = 0;
  const { s0, s1, count, phase = 0, twist = 0, fade = [0.1, 0.55] } = ribs;
  const r0 = ribs.r0 ?? ribs.r;
  const r1 = ribs.r1 ?? r0;
  const slope = (r1 - r0) / (s1 - s0 || 1);
  const norm = Math.hypot(1, slope);
  const view = geo.view;
  const seg = new Float64Array(4);
  for (let i = 0; i < count; i++) {
    const t = phase + (i / count) * T3_TAU;
    const tm = t + twist / 2;
    const facing = (view.flat * Math.cos(tm - view.phi) - slope * view.a) / norm;
    const alpha = t3Step(fade[0], fade[1], facing);
    if (alpha * T3_LEVELS < 0.5) continue;
    t3At(geo, s0, r0, t, seg, 0);
    t3At(geo, s1, r1, t + twist, seg, 2);
    seg[0] -= sx;
    seg[1] -= sy;
    seg[2] -= sx;
    seg[3] -= sy;
    lines.d.push(t3Path(seg, 2, false));
    lines.alpha.push(alpha);
    lines.key.push(i);
  }
  return lines;
}

function t3PrepareRound(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.F = spec.F;
  run.s0 = spec.s0;
  run.s1 = spec.s1;
  run.r0 = spec.r0;
  run.r1 = spec.r1;
  run.ends = spec.ends;
  const g = (spec.r1 - spec.r0) / (spec.s1 - spec.s0 || 1);
  const norm = Math.hypot(1, g);
  run.nr = 1 / norm;
  run.na = -g / norm;
  run.g = g;
  const rmax = Math.max(spec.r0, spec.r1);
  const around = T3_TAU * rmax * k * unit;
  run.fine = Math.max(around < 30 ? 16 : 24, Math.min(96, Math.ceil(around / 2.2)));
  run.dirs = Math.max(around < 30 ? 16 : 32, Math.min(256, Math.ceil(around / 1.5)));
  run.capCount = Math.max(around < 30 ? 16 : 24, Math.min(160, Math.ceil(around / 1.5)));
  run.domeCount = Math.max(around < 30 ? 12 : 16, Math.min(48, Math.round(run.fine * 0.75)));
  const comps = [];
  for (const [end, s, r] of [[0, spec.s0, spec.r0], [1, spec.s1, spec.r1]]) comps.push(spec.ends[end] === "dome" ? 1 : 0, s, r);
  run.components = comps;
  const details = part.details ?? {};
  run.seams = (details.seams ?? []).map((s) => ({ s, r: spec.r0 + g * (s - spec.s0), slope: g }));
  run.ribs = details.ribs ? { ...details.ribs, r0: details.ribs.r0 ?? spec.r0 + g * (details.ribs.s0 - spec.s0), r1: details.ribs.r1 ?? spec.r0 + g * (details.ribs.s1 - spec.s0) } : null;
  run.cutS = null;
  for (const cut of spec.cuts ?? []) {
    const along = cut.n[0] * spec.F.a[0] + cut.n[1] * spec.F.a[1] + cut.n[2] * spec.F.a[2];
    const sCut = (cut.d - (cut.n[0] * spec.F.o[0] + cut.n[1] * spec.F.o[1] + cut.n[2] * spec.F.o[2])) / along;
    if (Math.abs(sCut - spec.s0) < 1e-6 || Math.abs(sCut - spec.s1) < 1e-6) run.cutS = sCut;
  }
}

function t3EmitRound(run, cam, f) {
  const geo = t3Geo(run.F, cam);
  const k = cam.k;
  const step = T3_TAU / run.fine;
  const sheets = ["", "", "", "", ""];
  const { s0, s1, r0, r1 } = run;
  for (const arc of t3Arcs(run.nr, run.na, geo.view, geo.light)) sheets[arc.tone] += t3Strip(geo, s0, r0, s1, r1, arc.from, arc.to, step);
  const F = run.F;
  for (let end = 0; end < 2; end++) {
    if (run.ends[end] !== "dome") continue;
    const s = end ? s1 : s0;
    const sign = end ? 1 : -1;
    const C = [F.o[0] + F.a[0] * s, F.o[1] + F.a[1] * s, F.o[2] + F.a[2] * s];
    t3Dome(cam, C, [F.a[0] * sign, F.a[1] * sign, F.a[2] * sign], end ? r1 : r0, run.domeCount, sheets);
  }
  for (let t = 0; t < 5; t++) f.d[run.role[`s${t}`]] = sheets[t];
  const pts = t3RoundOutline(run, geo, cam);
  const outline = t3PathOf(pts, true);
  f.d[run.role.fill] = outline;
  if (run.cutS !== null) {
    const buf = new Float64Array(2 * pts.length);
    const skip = new Uint8Array(pts.length);
    pts.forEach((p, j) => {
      buf[2 * j] = p[0];
      buf[2 * j + 1] = p[1];
    });
    for (let j = 0; j < pts.length; j++) skip[j] = pts[j].cut && pts[(j + 1) % pts.length].cut ? 1 : 0;
    f.d[run.role.edge] = t3Broken(buf, pts.length, skip);
  } else f.d[run.role.edge] = outline;
  t3BoxOf(pts, f.box);
  t3HullOut(f, pts);
  const view = geo.view;
  let crease = "";
  for (let end = 0; end < 2; end++) {
    if (run.ends[end] !== "flat") continue;
    const role = run.role[`k${end}`];
    const sign = end ? 1 : -1;
    const s = end ? s1 : s0;
    const r = end ? r1 : r0;
    const face = sign * geo.aV;
    if (!(face > 1e-9)) {
      f.d[role] = "";
      continue;
    }
    const n = run.capCount;
    const buf = t3Scratch("c", 2 * n);
    const table = t3Table(n);
    for (let j = 0; j < n; j++) {
      const c = table[2 * j];
      const q = table[2 * j + 1];
      buf[2 * j] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + q * geo.SV[0]);
      buf[2 * j + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + q * geo.SV[1]);
    }
    f.d[role] = t3Path(buf, n, true);
    f.q[role] = t3Level(sign * geo.aL, T3_LB);
    const minor = 2 * r * k * Math.abs(face);
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    if (run.role.crease === undefined) continue;
    let lo;
    let hi;
    if (view.flat < 1e-9) {
      if (!(run.na * view.a > 1e-9)) continue;
      lo = 0;
      hi = T3_TAU;
    } else {
      const cc = -(run.na * view.a) / (run.nr * view.flat);
      if (cc >= 1) continue;
      if (cc <= -1) {
        lo = view.phi;
        hi = view.phi + T3_TAU;
      } else {
        const d = Math.acos(cc);
        lo = view.phi - d;
        hi = view.phi + d;
      }
    }
    const count = Math.min(400, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
    const arc = t3Scratch("c", 2 * count + 2);
    for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, arc, 2 * j);
    crease += t3Path(arc, count + 1, false);
  }
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  if (run.role.inner !== undefined) {
    let inner = "";
    for (const seam of run.seams) inner += t3SeamArc(geo, seam.s, seam.r, seam.slope, k);
    if (run.ribs && run.ribs.seams) {
      const rs = run.ribs;
      const slope = (rs.r1 - rs.r0) / (rs.s1 - rs.s0 || 1);
      if (rs.seams !== "last") inner += t3SeamArc(geo, rs.s0, rs.r0, slope, k);
      if (rs.seams !== "first") inner += t3SeamArc(geo, rs.s1, rs.r1, slope, k);
    }
    f.d[run.role.inner] = inner;
  }
  if (run.ribs) {
    const lines = t3State.detail < 0.01 ? t3None : t3RibLines(geo, run.F, cam, run.ribs, 0, 0);
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
}

function t3PrepareBall(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.c = spec.c;
  run.r = spec.r;
  run.flats = spec.flats.map((flat) => {
    const n = t3Unit(flat.n);
    const rho = Math.sqrt(Math.max(0, spec.r * spec.r - flat.d * flat.d));
    const [u, v] = t3Basis(n);
    return { n, d: flat.d, rho, u, v };
  });
  run.limb = Math.max(32, Math.min(160, Math.ceil((T3_TAU * spec.r * k * unit) / 1.5)));
  run.rimCount = run.flats.map((flat) => Math.max(20, Math.min(120, Math.ceil((T3_TAU * flat.rho * k * unit) / 1.5))));
}

function t3ClipConvex(subject, clip) {
  if (subject.length < 3 || clip.length < 3) return [];
  let area = 0;
  for (let i = 0; i < clip.length; i++) {
    const a = clip[i];
    const b = clip[(i + 1) % clip.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = area >= 0 ? 1 : -1;
  let out = subject;
  for (let i = 0; i < clip.length && out.length; i++) {
    const a = clip[i];
    const b = clip[(i + 1) % clip.length];
    const side = (p) => sign * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
    const input = out;
    out = [];
    for (let j = 0; j < input.length; j++) {
      const p = input[j];
      const q = input[(j + 1) % input.length];
      const sp = side(p);
      const sq = side(q);
      if (sp >= 0) out.push(p);
      if ((sp >= 0) !== (sq >= 0)) {
        const t = sp / (sp - sq);
        out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
      }
    }
  }
  return out;
}

function t3EmitBall(run, cam, f) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const { c, r, flats } = run;
  const Cx = cam.ox + m[0] * c[0] + m[1] * c[1] + m[2] * c[2];
  const Cy = cam.oy + m[3] * c[0] + m[4] * c[1] + m[5] * c[2];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const flat = (w) => [Cx + r * (m[0] * w[0] + m[1] * w[1] + m[2] * w[2]), Cy + r * (m[3] * w[0] + m[4] * w[1] + m[5] * w[2])];
  const inside = (w, skip = -1) => {
    for (let i = 0; i < flats.length; i++) if (i !== skip && dot(w, flats[i].n) * r > flats[i].d + 1e-7) return false;
    return true;
  };
  const [U, W] = t3Basis(V);
  const n = run.limb;
  const table = t3Table(n);
  const limb = [];
  for (let j = 0; j < n; j++) limb.push([U[0] * table[2 * j] + W[0] * table[2 * j + 1], U[1] * table[2 * j] + W[1] * table[2 * j + 1], U[2] * table[2 * j] + W[2] * table[2 * j + 1]]);
  const sil = [];
  for (const w of limb) if (inside(w)) sil.push(flat(w));
  const rims = flats.map((cut, ci) => {
    const count = run.rimCount[ci];
    const t = t3Table(count);
    const list = [];
    for (let j = 0; j < count; j++) {
      const w = [(cut.n[0] * cut.d + cut.rho * (cut.u[0] * t[2 * j] + cut.v[0] * t[2 * j + 1])) / r, (cut.n[1] * cut.d + cut.rho * (cut.u[1] * t[2 * j] + cut.v[1] * t[2 * j + 1])) / r, (cut.n[2] * cut.d + cut.rho * (cut.u[2] * t[2 * j] + cut.v[2] * t[2 * j + 1])) / r];
      list.push({ w, keep: inside(w, ci), p: flat(w) });
    }
    return list;
  });
  flats.forEach((cut, ci) => {
    for (const w of t3Meet(V, 0, cut.n, cut.d / r)) if (inside(w, ci)) sil.push(flat(w));
    rims[ci].forEach(({ keep, p }) => keep && sil.push(p));
  });
  const hull = t3Hull(sil);
  const outline = t3PathOf(hull, true);
  f.d[run.role.fill] = outline;
  f.d[run.role.edge] = outline;
  f.d[run.role.s0] = outline;
  const capOf = (threshold) => {
    const points = [];
    for (const w of limb) if (dot(w, L) >= threshold && inside(w)) points.push(flat(w));
    const radius = Math.sqrt(Math.max(0, 1 - threshold * threshold));
    const [LU, LW] = t3Basis(L);
    for (let j = 0; j < n; j++) {
      const w = [L[0] * threshold + radius * (LU[0] * table[2 * j] + LW[0] * table[2 * j + 1]), L[1] * threshold + radius * (LU[1] * table[2 * j] + LW[1] * table[2 * j + 1]), L[2] * threshold + radius * (LU[2] * table[2 * j] + LW[2] * table[2 * j + 1])];
      if (dot(w, V) >= -1e-9) points.push(flat(w));
    }
    if (dot(L, V) > threshold) points.push(flat(V));
    for (const w of t3Meet(L, threshold, V, 0)) if (inside(w)) points.push(flat(w));
    flats.forEach((cut, ci) => {
      for (const w of t3Meet(V, 0, cut.n, cut.d / r)) if (inside(w, ci) && dot(w, L) >= threshold) points.push(flat(w));
      for (const w of t3Meet(L, threshold, cut.n, cut.d / r)) if (inside(w, ci) && dot(w, V) >= -1e-9) points.push(flat(w));
      for (const { w, keep, p } of rims[ci]) if (keep && dot(w, L) >= threshold && dot(w, V) >= -1e-9) points.push(p);
    });
    if (points.length < 3) return "";
    const region = t3ClipConvex(t3Hull(points), hull);
    return region.length >= 3 ? t3PathOf(region, true) : "";
  };
  f.d[run.role.s1] = capOf(T3_LB[1]);
  f.d[run.role.s2] = capOf(T3_LB[2]);
  f.d[run.role.s3] = capOf(T3_LB[3]);
  let crease = "";
  flats.forEach((cut, ci) => {
    const role = run.role[`f${ci}`];
    const face = dot(cut.n, V);
    if (!(face > 1e-9)) {
      f.d[role] = "";
      return;
    }
    const pts = t3ClipConvex(rims[ci].map(({ p }) => p), hull);
    f.d[role] = t3PathOf(pts, true);
    f.q[role] = t3Level(dot(cut.n, L), T3_LB);
    const minor = 2 * cut.rho * cam.k * face;
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    const list = rims[ci];
    const seen = list.map(({ w, keep }) => keep && dot(w, V) > 0);
    const start = seen.indexOf(false);
    if (start < 0) {
      crease += t3PathOf(list.map(({ p }) => p), true);
      return;
    }
    let cur = null;
    for (let s = 1; s <= list.length; s++) {
      const i = (start + s) % list.length;
      if (seen[i]) (cur ??= []).push(list[i].p);
      else if (cur) {
        if (cur.length > 1) crease += t3PathOf(cur, false);
        cur = null;
      }
    }
    if (cur && cur.length > 1) crease += t3PathOf(cur, false);
  });
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const [x, y] of hull) {
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  f.box[0] = x0;
  f.box[1] = y0;
  f.box[2] = x1;
  f.box[3] = y1;
  t3HullOut(f, hull);
}

function t3PrepareLathe(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.F = spec.F;
  run.levels = spec.levels;
  run.caps = spec.caps;
  let rmax = 0;
  for (let i = 0; i < spec.levels.length; i += 4) rmax = Math.max(rmax, spec.levels[i + 1]);
  run.fine = Math.max(24, Math.min(96, Math.ceil((T3_TAU * rmax * k * unit) / 2.2)));
  run.dirs = Math.max(32, Math.min(256, Math.ceil((T3_TAU * rmax * k * unit) / 1.5)));
  run.capCount = Math.max(24, Math.min(160, Math.ceil((T3_TAU * rmax * k * unit) / 1.5)));
  const comps = [];
  for (let i = 0; i < spec.levels.length; i += 4) comps.push(0, spec.levels[i], spec.levels[i + 1]);
  run.components = comps;
  const details = part.details ?? {};
  run.seams = (details.seams ?? []).map((seam) => (typeof seam === "number" ? t3LatheSeam(spec.levels, seam) : seam));
  run.ribs = details.ribs ?? null;
}

function t3LatheSeam(levels, s) {
  for (let i = 0; i + 4 < levels.length; i += 4) {
    const sa = levels[i];
    const sb = levels[i + 4];
    if ((s - sa) * (s - sb) <= 0 && sa !== sb) {
      const t = (s - sa) / (sb - sa);
      const r = levels[i + 1] + (levels[i + 5] - levels[i + 1]) * t;
      const slope = (levels[i + 5] - levels[i + 1]) / (sb - sa);
      return { s, r, slope };
    }
  }
  return { s, r: levels[1], slope: 0 };
}

function t3LatheSheets(levels, geo, step, buckets) {
  const at = (L, t) => {
    const c = Math.cos(t);
    const n = Math.sin(t);
    return [geo.O[0] + L.s * geo.SA[0] + L.r * (c * geo.SU[0] + n * geo.SV[0]), geo.O[1] + L.s * geo.SA[1] + L.r * (c * geo.SU[1] + n * geo.SV[1])];
  };
  const arcPoints = (L, from, to) => {
    const count = Math.max(1, Math.ceil((to - from) / step));
    const out = new Array(count + 1);
    for (let index = 0; index <= count; index++) out[index] = at(L, from + ((to - from) * index) / count);
    return out;
  };
  const oriented = (points) => {
    let area = 0;
    for (let i = 0; i < points.length; i++) {
      const a = points[i];
      const b = points[(i + 1) % points.length];
      area += a[0] * b[1] - b[0] * a[1];
    }
    return area < 0 ? points.reverse() : points;
  };
  const centreOf = (arc, reference) => {
    let mid = (arc.from + arc.to) / 2 - reference;
    mid = ((mid % T3_TAU) + T3_TAU) % T3_TAU;
    return mid;
  };
  const present = [false, false, false, false, false];
  for (const L of levels) {
    const groups = [null, null, null, null, null];
    for (const arc of L.arcs) {
      if (!groups[arc.tone]) groups[arc.tone] = [];
      groups[arc.tone].push(arc);
      present[arc.tone] = true;
    }
    for (const list of groups) if (list && list.length > 1) list.sort((x, y) => centreOf(x, geo.light.phi) - centreOf(y, geo.light.phi));
    L.groups = groups;
  }
  const align = (reference, arc) => {
    const out = { ...arc };
    const centre = (reference.from + reference.to) / 2;
    while ((out.from + out.to) / 2 - centre > Math.PI) {
      out.from -= T3_TAU;
      out.to -= T3_TAU;
    }
    while (centre - (out.from + out.to) / 2 > Math.PI) {
      out.from += T3_TAU;
      out.to += T3_TAU;
    }
    return out;
  };
  const overlap = (x, y) => Math.min(x.to, y.to) - Math.max(x.from, y.from);
  const sheet = (tone, run) => {
    const chains = run[0].groups[tone].map((arc) => [arc]);
    for (let q = 1; q < run.length; q++) {
      const next = run[q].groups[tone];
      const used = new Uint8Array(next.length);
      for (const chain of chains) {
        const prev = chain[q - 1];
        let best = -1;
        let score = -Infinity;
        for (let j = 0; j < next.length; j++) {
          if (used[j]) continue;
          const value = overlap(prev, align(prev, next[j]));
          if (value > score) {
            score = value;
            best = j;
          }
        }
        used[best] = 1;
        chain.push(align(prev, next[best]));
      }
    }
    chains.forEach((chain) => {
      const last = run.length - 1;
      const points = arcPoints(run[0], chain[0].from, chain[0].to);
      for (let q = 1; q < last; q++) points.push(at(run[q], chain[q].to));
      points.push(...arcPoints(run[last], chain[last].from, chain[last].to).reverse());
      for (let q = last - 1; q >= 1; q--) points.push(at(run[q], chain[q].from));
      buckets[tone] += t3PathOf(oriented(points), true);
    });
  };
  const strip = (tone, a, b) => {
    const below = a.groups[tone] ?? [];
    const above = b.groups[tone] ?? [];
    const whole = (arc) => arc.to - arc.from >= T3_TAU - 1e-6;
    const seat = (ring, arc) => {
      const gap = T3_TAU - (arc.to - arc.from);
      return { from: arc.from - gap / 2, to: arc.to + gap / 2 };
    };
    const quad = (lo, hi) => {
      if (whole(hi) && !whole(lo)) hi = seat(hi, lo);
      else if (whole(lo) && !whole(hi)) lo = seat(lo, hi);
      const points = [...arcPoints(a, lo.from, lo.to), ...arcPoints(b, hi.from, hi.to).reverse()];
      buckets[tone] += t3PathOf(oriented(points), true);
    };
    const links = below.map(() => []);
    const back = above.map(() => []);
    below.forEach((lo, i) =>
      above.forEach((hi, j) => {
        if (overlap(lo, align(lo, hi)) > -0.05) {
          links[i].push(j);
          back[j].push(i);
        }
      }),
    );
    const doneLo = new Uint8Array(below.length);
    const doneHi = new Uint8Array(above.length);
    const share = (span, parts, flip) => {
      if (whole(span)) {
        const base = parts[0].from;
        const sorted = parts.map((arc) => {
          const shift = T3_TAU * Math.floor((arc.from - base) / T3_TAU);
          return { from: arc.from - shift, to: arc.to - shift };
        }).sort((x, y) => x.from - y.from);
        const n = sorted.length;
        const cuts = sorted.map((arc, q) => {
          const next = q + 1 < n ? sorted[q + 1].from : sorted[0].from + T3_TAU;
          return (arc.to + next) / 2;
        });
        sorted.forEach((arc, q) => {
          const piece = { from: q ? cuts[q - 1] : cuts[n - 1] - T3_TAU, to: cuts[q] };
          if (flip) quad(arc, piece);
          else quad(piece, arc);
        });
        return;
      }
      const sorted = parts.map((arc) => align(span, arc)).sort((x, y) => x.from - y.from);
      const cuts = [span.from];
      for (let q = 1; q < sorted.length; q++) cuts.push(Math.min(span.to, Math.max(span.from, (sorted[q - 1].to + sorted[q].from) / 2)));
      cuts.push(span.to);
      sorted.forEach((arc, q) => {
        const piece = { from: cuts[q], to: cuts[q + 1] };
        if (flip) quad(arc, piece);
        else quad(piece, arc);
      });
    };
    for (let j = 0; j < above.length; j++) {
      if (back[j].length < 2 || back[j].some((i) => links[i].length !== 1)) continue;
      share(above[j], back[j].map((i) => below[i]), false);
      doneHi[j] = 1;
      for (const i of back[j]) doneLo[i] = 1;
    }
    for (let i = 0; i < below.length; i++) {
      if (doneLo[i] || links[i].length < 2 || links[i].some((j) => back[j].length !== 1)) continue;
      share(below[i], links[i].map((j) => above[j]), true);
      doneLo[i] = 1;
      for (const j of links[i]) doneHi[j] = 1;
    }
    for (let i = 0; i < below.length; i++) {
      if (doneLo[i]) continue;
      const lo = below[i];
      let best = -1;
      let score = -Infinity;
      for (const j of links[i]) {
        if (doneHi[j]) continue;
        const value = overlap(lo, align(lo, above[j]));
        if (value > score) {
          score = value;
          best = j;
        }
      }
      if (best >= 0) {
        doneHi[best] = 1;
        quad(lo, align(lo, above[best]));
      } else {
        const centre = (lo.from + lo.to) / 2;
        quad(lo, { from: centre, to: centre });
      }
    }
    for (let j = 0; j < above.length; j++) {
      if (doneHi[j]) continue;
      const centre = (above[j].from + above[j].to) / 2;
      quad({ from: centre, to: centre }, above[j]);
    }
  };
  for (let tone = 0; tone < 5; tone++) {
    if (!present[tone]) continue;
    for (let q = 1; q < levels.length; q++) strip(tone, levels[q - 1], levels[q]);
  }
  void sheet;
}

function t3EmitLathe(run, cam, f) {
  const geo = t3Geo(run.F, cam);
  const k = cam.k;
  const step = T3_TAU / run.fine;
  const sheets = ["", "", "", "", ""];
  const lv = run.levels;
  const levelAt = (s, r, nr, na) => ({ s, r, nr, na, arcs: t3Arcs(nr, na, geo.view, geo.light) });
  const same = (a, b) => {
    if (a.arcs.length !== b.arcs.length) return false;
    for (let i = 0; i < a.arcs.length; i++) if (a.arcs[i].tone !== b.arcs[i].tone || Boolean(a.arcs[i].full) !== Boolean(b.arcs[i].full)) return false;
    return true;
  };
  const reach = (a, b) => Math.hypot((b.s - a.s) * Math.hypot(geo.SA[0], geo.SA[1]), (b.r - a.r) * k) + Math.abs(b.r - a.r) * k;
  const levels = [];
  const wrap = (x, ref) => x + T3_TAU * Math.round((ref - x) / T3_TAU);
  const close = (a, mid, b) => {
    const r = Math.max(a.r, b.r) * k;
    for (let i = 0; i < mid.arcs.length; i++) {
      const m = mid.arcs[i];
      if (m.full) continue;
      for (const key of ["from", "to"]) {
        const x = m[key];
        const guess = (wrap(a.arcs[i][key], x) + wrap(b.arcs[i][key], x)) / 2;
        if (Math.abs(guess - x) * r > 0.25) return false;
      }
    }
    return true;
  };
  const refine = (a, b, depth) => {
    if (depth >= 8 || reach(a, b) < 0.1) {
      levels.push(b);
      return;
    }
    const m = t3LatheMid(a, b);
    const mid = levelAt(m.s, m.r, m.nr, m.na);
    if (same(a, b) && same(a, mid) && close(a, mid, b)) {
      levels.push(b);
      return;
    }
    refine(a, mid, depth + 1);
    refine(mid, b, depth + 1);
  };
  let previous = levelAt(lv[0], lv[1], lv[2], lv[3]);
  levels.push(previous);
  for (let i = 4; i < lv.length; i += 4) {
    const next = levelAt(lv[i], lv[i + 1], lv[i + 2], lv[i + 3]);
    refine(previous, next, 0);
    previous = next;
  }
  t3LatheSheets(levels, geo, step, sheets);
  for (let t = 0; t < 5; t++) f.d[run.role[`s${t}`]] = sheets[t];
  const pts = t3LatheOutline(run, geo, cam);
  const outline = t3PathOf(pts, true);
  f.d[run.role.fill] = outline;
  f.d[run.role.edge] = outline;
  t3BoxOf(pts, f.box);
  t3HullOut(f, pts);
  let crease = "";
  const view = geo.view;
  const ends = [[0, lv[0], lv[1], -1, 4], [1, lv[lv.length - 4], lv[lv.length - 3], 1, lv.length - 8]];
  for (const [end, s, r, sign, near] of ends) {
    const role = run.role[`k${end}`];
    if (role === undefined) continue;
    const face = sign * geo.aV;
    if (!(face > 1e-9) || r <= 1e-9) {
      f.d[role] = "";
      continue;
    }
    const n = run.capCount;
    const buf = t3Scratch("c", 2 * n);
    const table = t3Table(n);
    for (let j = 0; j < n; j++) {
      const c = table[2 * j];
      const q = table[2 * j + 1];
      buf[2 * j] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + q * geo.SV[0]);
      buf[2 * j + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + q * geo.SV[1]);
    }
    f.d[role] = t3Path(buf, n, true);
    f.q[role] = t3Level(sign * geo.aL, T3_LB);
    const minor = 2 * r * k * Math.abs(face);
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    if (run.role.crease === undefined) continue;
    const nr = lv[near + 2];
    const na = lv[near + 3];
    let lo;
    let hi;
    if (view.flat < 1e-9) {
      if (!(na * view.a > 1e-9)) continue;
      lo = 0;
      hi = T3_TAU;
    } else {
      const cc = -(na * view.a) / (nr * view.flat);
      if (cc >= 1) continue;
      if (cc <= -1) {
        lo = view.phi;
        hi = view.phi + T3_TAU;
      } else {
        const d = Math.acos(cc);
        lo = view.phi - d;
        hi = view.phi + d;
      }
    }
    const count = Math.min(400, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
    const arc = t3Scratch("c", 2 * count + 2);
    for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, arc, 2 * j);
    crease += t3Path(arc, count + 1, false);
  }
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  if (run.role.inner !== undefined) {
    let inner = "";
    for (const seam of run.seams) inner += t3SeamArc(geo, seam.s, seam.r, seam.slope, k);
    f.d[run.role.inner] = inner;
  }
  if (run.ribs) {
    const lines = t3State.detail < 0.01 ? t3None : t3RibLines(geo, run.F, cam, run.ribs, 0, 0);
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
}

const T3_SHINE = (() => {
  const l = Math.hypot(-0.55, -0.835);
  return [-0.55 / l, -0.835 / l];
})();

function t3RouteOf(flat) {
  const n = flat.length / 3;
  const along = new Float64Array(n);
  for (let i = 1; i < n; i++) along[i] = along[i - 1] + Math.hypot(flat[3 * i] - flat[3 * i - 3], flat[3 * i + 1] - flat[3 * i - 2], flat[3 * i + 2] - flat[3 * i - 1]);
  return { flat, n, along, total: along[n - 1] };
}

function t3PointAlong(route, at) {
  const { flat, n, along } = route;
  if (at <= 0) return [flat[0], flat[1], flat[2]];
  if (at >= along[n - 1]) return [flat[3 * n - 3], flat[3 * n - 2], flat[3 * n - 1]];
  let lo = 0;
  let hi = n - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (along[mid] <= at) lo = mid;
    else hi = mid;
  }
  const span = along[hi] - along[lo] || 1;
  const t = (at - along[lo]) / span;
  return [flat[3 * lo] + (flat[3 * hi] - flat[3 * lo]) * t, flat[3 * lo + 1] + (flat[3 * hi + 1] - flat[3 * lo + 1]) * t, flat[3 * lo + 2] + (flat[3 * hi + 2] - flat[3 * lo + 2]) * t];
}

function t3Cut(route, from, to) {
  const { flat, n, along } = route;
  const out = [];
  for (let i = 1; i < n; i++) {
    const start = along[i - 1];
    const end = along[i];
    if (end >= from && start <= to) {
      const length = end - start || 1;
      const t0 = Math.max(0, (from - start) / length);
      const t1 = Math.min(1, (to - start) / length);
      const at = (t) => [flat[3 * i - 3] + (flat[3 * i] - flat[3 * i - 3]) * t, flat[3 * i - 2] + (flat[3 * i + 1] - flat[3 * i - 2]) * t, flat[3 * i - 1] + (flat[3 * i + 2] - flat[3 * i - 1]) * t];
      if (!out.length) out.push(at(t0));
      out.push(at(t1));
    }
  }
  return out;
}

function t3TangentAlong(route, at) {
  const a = t3PointAlong(route, Math.max(0, at - 0.6));
  const b = t3PointAlong(route, Math.min(route.total, at + 0.6));
  return t3Unit([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
}

const t3RouteCache = new WeakMap();

function t3PrepareTube(run, part, data) {
  const spec = part.spec;
  const source = data.routes[spec.route];
  let route = t3RouteCache.get(source);
  if (!route) {
    route = t3RouteOf(source.points);
    t3RouteCache.set(source, route);
  }
  const total = route.total;
  const r = source.r;
  const step = Math.max(0.05, Math.min(source.spacing, total / 4) * 0.5);
  const lead = 0.5;
  run.r = r;
  run.wide = Boolean(source.wide);
  run.hue = source.hue ?? null;
  const partOf = (start, end) => {
    const points = t3Cut(route, Math.max(0, start), Math.min(total, end));
    let walked = Math.max(0, start);
    const count = points.length;
    const P3 = new Float64Array(3 * count);
    const D3 = new Float64Array(3 * count);
    for (let i = 0; i < count; i++) {
      if (i > 0) walked += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1], points[i][2] - points[i - 1][2]);
      const a = t3PointAlong(route, Math.max(0, Math.min(total, walked - step)));
      const b = t3PointAlong(route, Math.max(0, Math.min(total, walked + step)));
      for (let c = 0; c < 3; c++) {
        P3[3 * i + c] = points[i][c];
        D3[3 * i + c] = b[c] - a[c];
      }
    }
    return { count, P3, D3 };
  };
  const openStart = spec.open0 && spec.from > 0;
  const openEnd = spec.open1 && spec.to < total;
  run.body = partOf(openStart ? spec.from - lead : spec.from, openEnd ? spec.to + lead : spec.to);
  run.stripe = openStart || openEnd ? partOf(openStart ? spec.from - 2 * lead : spec.from, openEnd ? spec.to + 2 * lead : spec.to) : run.body;
  const ends = [!spec.open0 && (spec.from <= 1e-6 || spec.gap0), !spec.open1 && (spec.to >= total - 1e-6 || spec.gap1)];
  run.capped = [ends[0] && (spec.gap0 || spec.caps[0]), ends[1] && (spec.gap1 || spec.caps[1])];
  const P3 = run.body.P3;
  const last = run.body.count - 1;
  const vec = (i, j) => t3Unit([P3[3 * i] - P3[3 * j], P3[3 * i + 1] - P3[3 * j + 1], P3[3 * i + 2] - P3[3 * j + 2]]);
  run.tips = [vec(1, 0), vec(last, last - 1)];
  run.rings = null;
  if (source.rings) {
    const { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16 } = source.rings;
    const lo = openStart ? spec.from - 2 * lead : spec.from;
    const hi = openEnd ? spec.to + 2 * lead : spec.to;
    const margin = twist ? 0 : Math.min(pitch * 0.35, (hi - lo) / 2);
    const from = Math.max(lo, !openStart ? lo + margin : lo);
    const to = Math.min(hi, !openEnd ? hi - margin : hi);
    const segments = [];
    if (to > from) {
      const hands = twist ? (cross ? [1, -1] : [1]) : [0];
      const reach = Math.abs(twist) * r;
      const first = Math.ceil((from - reach - phase) / pitch);
      const lastIndex = Math.floor((to + reach - phase) / pitch);
      const surface = (t, theta) => {
        const at = Math.max(0, Math.min(total, t));
        return { c: t3PointAlong(route, at), T: t3TangentAlong(route, at), theta };
      };
      for (let index = first; index <= lastIndex; index++) {
        const station = phase + index * pitch;
        for (const hand of hands) {
          const samples = [];
          for (let k = 0; k <= steps; k++) {
            const theta = (k / steps) * Math.PI;
            const t = station + hand * twist * r * (k / steps - 0.5);
            samples.push({ t, theta });
          }
          for (let k = 0; k < steps; k++) {
            let a = samples[k];
            let b = samples[k + 1];
            if ((a.t < from && b.t < from) || (a.t > to && b.t > to)) continue;
            const clipTo = (x, y, edge) => ({ t: edge, theta: x.theta + ((y.theta - x.theta) * (edge - x.t)) / (y.t - x.t || 1e-9) });
            if (a.t < from) a = clipTo(a, b, from);
            if (b.t < from) b = clipTo(b, a, from);
            if (a.t > to) a = clipTo(a, b, to);
            if (b.t > to) b = clipTo(b, a, to);
            segments.push([surface(a.t, a.theta), surface(b.t, b.theta)]);
          }
        }
      }
    }
    run.rings = { segments, fade, tone: source.rings.tone ?? "lo" };
  }
}

function t3Sides(part, cam, r) {
  const m = cam.m;
  const k = cam.k;
  const n = part.count;
  const out = { n, flat: new Float64Array(2 * n), left: new Float64Array(2 * n), right: new Float64Array(2 * n), shineA: new Float64Array(2 * n), shineB: new Float64Array(2 * n), shadeA: new Float64Array(2 * n), shadeB: new Float64Array(2 * n), frames: [] };
  const half = r * k;
  for (let i = 0; i < n; i++) {
    const x = part.P3[3 * i];
    const y = part.P3[3 * i + 1];
    const z = part.P3[3 * i + 2];
    const px = cam.ox + m[0] * x + m[1] * y + m[2] * z;
    const py = cam.oy + m[3] * x + m[4] * y + m[5] * z;
    const dx = m[0] * part.D3[3 * i] + m[1] * part.D3[3 * i + 1] + m[2] * part.D3[3 * i + 2];
    const dy = m[3] * part.D3[3 * i] + m[4] * part.D3[3 * i + 1] + m[5] * part.D3[3 * i + 2];
    const l = Math.hypot(dx, dy) || 1;
    const nx = -dy / l;
    const ny = dx / l;
    out.flat[2 * i] = px;
    out.flat[2 * i + 1] = py;
    out.frames.push({ p: [px, py], nx, ny, half });
    out.left[2 * i] = px + nx * half;
    out.left[2 * i + 1] = py + ny * half;
    out.right[2 * i] = px - nx * half;
    out.right[2 * i + 1] = py - ny * half;
    const toward = nx * T3_SHINE[0] + ny * T3_SHINE[1];
    const shineAt = half * 0.44 * toward;
    const shineHalf = half * 0.24;
    out.shineA[2 * i] = px + nx * (shineAt + shineHalf);
    out.shineA[2 * i + 1] = py + ny * (shineAt + shineHalf);
    out.shineB[2 * i] = px + nx * (shineAt - shineHalf);
    out.shineB[2 * i + 1] = py + ny * (shineAt - shineHalf);
    const shadeAt = -half * 0.62 * toward;
    const shadeHalf = half * 0.2;
    out.shadeA[2 * i] = px + nx * (shadeAt + shadeHalf);
    out.shadeA[2 * i + 1] = py + ny * (shadeAt + shadeHalf);
    out.shadeB[2 * i] = px + nx * (shadeAt - shadeHalf);
    out.shadeB[2 * i + 1] = py + ny * (shadeAt - shadeHalf);
  }
  return out;
}

const t3Pts = (buf, n) => {
  const out = new Array(n);
  for (let i = 0; i < n; i++) out[i] = [buf[2 * i], buf[2 * i + 1]];
  return out;
};

function t3TubeCap(cam, centre, tangent, radius, inward) {
  const V = cam.V;
  const m = cam.m;
  let n1 = [tangent[1] * V[2] - tangent[2] * V[1], tangent[2] * V[0] - tangent[0] * V[2], tangent[0] * V[1] - tangent[1] * V[0]];
  if (Math.hypot(n1[0], n1[1], n1[2]) < 1e-6) {
    const helper = Math.abs(tangent[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
    n1 = [helper[1] * tangent[2] - helper[2] * tangent[1], helper[2] * tangent[0] - helper[0] * tangent[2], helper[0] * tangent[1] - helper[1] * tangent[0]];
  }
  n1 = t3Unit(n1);
  const n2 = t3Unit([tangent[1] * n1[2] - tangent[2] * n1[1], tangent[2] * n1[0] - tangent[0] * n1[2], tangent[0] * n1[1] - tangent[1] * n1[0]]);
  const steps = 28;
  const proj = (p) => [cam.ox + m[0] * p[0] + m[1] * p[1] + m[2] * p[2], cam.oy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2]];
  const ring = new Array(steps);
  for (let index = 0; index < steps; index++) {
    const angle = (index / steps) * T3_TAU;
    const cs = radius * Math.cos(angle);
    const sn = radius * Math.sin(angle);
    ring[index] = proj([centre[0] + n1[0] * cs + n2[0] * sn, centre[1] + n1[1] * cs + n2[1] * sn, centre[2] + n1[2] * cs + n2[2] * sn]);
  }
  const C = proj(centre);
  const probe = proj([centre[0] + n2[0], centre[1] + n2[1], centre[2] + n2[2]]);
  const along = (probe[0] - C[0]) * inward[0] + (probe[1] - C[1]) * inward[1];
  const from = along > 0 ? steps / 2 : 0;
  const half = (start) => Array.from({ length: steps / 2 + 1 }, (_, index) => ring[(start + index) % steps]);
  return { ring, arc: half(from), inner: half((from + steps / 2) % steps) };
}

function t3StripeEnd(half, centre, nx, ny, from, to) {
  const offset = (p) => (p[0] - centre[0]) * nx + (p[1] - centre[1]) * ny;
  const values = half.map(offset);
  const lo = Math.min(from, to);
  const hi = Math.max(from, to);
  const points = [];
  const cross = (k, level) => {
    const t = (level - values[k]) / (values[k + 1] - values[k] || 1e-9);
    return [half[k][0] + (half[k + 1][0] - half[k][0]) * t, half[k][1] + (half[k + 1][1] - half[k][1]) * t];
  };
  for (let k = 0; k < half.length; k++) {
    if (values[k] >= lo && values[k] <= hi) points.push(half[k]);
    if (k < half.length - 1) for (const level of [lo, hi]) if ((values[k] - level) * (values[k + 1] - level) < 0) points.push(cross(k, level));
  }
  return points
    .map((p) => [p, offset(p)])
    .sort((x, y) => (from > to ? y[1] - x[1] : x[1] - y[1]))
    .map(([p]) => p);
}

function t3EmitTube(run, cam, f) {
  const r = run.r;
  const sides = t3Sides(run.body, cam, r);
  const bands = run.stripe === run.body ? sides : t3Sides(run.stripe, cam, r);
  const n = sides.n;
  const left = t3Pts(sides.left, n);
  const right = t3Pts(sides.right, n);
  const loop = left.concat(right.slice().reverse());
  let area = 0;
  for (let i = 0; i < loop.length; i++) {
    const a = loop[i];
    const b = loop[(i + 1) % loop.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = Math.sign(area);
  let body = t3PathOf(loop, true);
  let edges = t3PathOf(left, false) + t3PathOf(right, false);
  const tips = [null, null];
  const last = n - 1;
  const quads = [];
  for (let i = 0; i < last; i++) quads.push([left[i], left[i + 1], right[i + 1], right[i]]);
  for (const side of [0, 1]) {
    if (!run.capped[side]) continue;
    const at = side ? last : 0;
    const other = side ? last - 1 : 1;
    const tangent = run.tips[side];
    const outward = side ? tangent : [-tangent[0], -tangent[1], -tangent[2]];
    const inward = [sides.flat[2 * other] - sides.flat[2 * at], sides.flat[2 * other + 1] - sides.flat[2 * at + 1]];
    const centre = [run.body.P3[3 * at], run.body.P3[3 * at + 1], run.body.P3[3 * at + 2]];
    const { ring, arc, inner } = t3TubeCap(cam, centre, tangent, r, inward);
    let ringArea = 0;
    for (let i = 0; i < ring.length; i++) {
      const a = ring[i];
      const b = ring[(i + 1) % ring.length];
      ringArea += a[0] * b[1] - b[0] * a[1];
    }
    body += t3PathOf(Math.sign(ringArea) === sign ? ring : ring.slice().reverse(), true);
    edges += t3PathOf(arc, false);
    const V = cam.V;
    const open = outward[0] * V[0] + outward[1] * V[1] + outward[2] * V[2] > 0;
    if (open) edges += t3PathOf(inner, false);
    tips[side] = { half: open ? inner : arc, frame: sides.frames[at] };
    quads.push(ring);
  }
  f.d[run.role.body] = body;
  f.d[run.role.edges] = edges;
  if (run.wide) {
    const toward = (frame) => frame.nx * T3_SHINE[0] + frame.ny * T3_SHINE[1];
    const shineOffsets = (frame) => [frame.half * 0.44 * toward(frame) + frame.half * 0.24, frame.half * 0.44 * toward(frame) - frame.half * 0.24];
    const shadeOffsets = (frame) => [-frame.half * 0.62 * toward(frame) + frame.half * 0.2, -frame.half * 0.62 * toward(frame) - frame.half * 0.2];
    const bn = bands.n;
    const band = (A, B, offsets) => {
      const head = tips[1] ? t3StripeEnd(tips[1].half, tips[1].frame.p, tips[1].frame.nx, tips[1].frame.ny, offsets(tips[1].frame)[0], offsets(tips[1].frame)[1]) : [];
      const tail = tips[0] ? t3StripeEnd(tips[0].half, tips[0].frame.p, tips[0].frame.nx, tips[0].frame.ny, offsets(tips[0].frame)[1], offsets(tips[0].frame)[0]) : [];
      return t3PathOf([...t3Pts(A, bn), ...head, ...t3Pts(B, bn).reverse(), ...tail], true);
    };
    f.d[run.role.shine] = band(bands.shineA, bands.shineB, shineOffsets);
    f.d[run.role.shade] = band(bands.shadeA, bands.shadeB, shadeOffsets);
  }
  if (run.rings) {
    const lines = t3Lines;
    lines.d.length = 0;
    lines.alpha.length = 0;
  lines.key.length = 0;
    lines.key.length = 0;
    const V = cam.V;
    const m = cam.m;
    const seg = new Float64Array(4);
    const surface = (sample, out, index) => {
      const T = sample.T;
      let n1 = [T[1] * V[2] - T[2] * V[1], T[2] * V[0] - T[0] * V[2], T[0] * V[1] - T[1] * V[0]];
      const l1 = Math.hypot(n1[0], n1[1], n1[2]);
      if (l1 < 1e-6) return null;
      n1 = [n1[0] / l1, n1[1] / l1, n1[2] / l1];
      let n2 = t3Unit([T[1] * n1[2] - T[2] * n1[1], T[2] * n1[0] - T[0] * n1[2], T[0] * n1[1] - T[1] * n1[0]]);
      if (n2[0] * V[0] + n2[1] * V[1] + n2[2] * V[2] < 0) n2 = [-n2[0], -n2[1], -n2[2]];
      const cs = Math.cos(sample.theta);
      const sn = Math.sin(sample.theta);
      const normal = [n1[0] * cs + n2[0] * sn, n1[1] * cs + n2[1] * sn, n1[2] * cs + n2[2] * sn];
      const p = [sample.c[0] + normal[0] * r, sample.c[1] + normal[1] * r, sample.c[2] + normal[2] * r];
      out[index] = cam.ox + m[0] * p[0] + m[1] * p[1] + m[2] * p[2];
      out[index + 1] = cam.oy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2];
      return normal[0] * V[0] + normal[1] * V[1] + normal[2] * V[2];
    };
    const fade = run.rings.fade;
    let index = -1;
    for (const [a, b] of run.rings.segments) {
      index++;
      const fa = surface(a, seg, 0);
      const fb = surface(b, seg, 2);
      if (fa === null || fb === null) continue;
      const alpha = fade ? t3Step(fade[0], fade[1], (fa + fb) / 2) : 1;
      if (alpha * T3_LEVELS < 0.5) continue;
      lines.d.push(t3Path(seg, 2, false));
      lines.alpha.push(alpha);
      lines.key.push(index);
    }
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
  const all = left.concat(right);
  t3BoxOf(all, f.box);
  for (const q of quads) for (const p of q) {
    if (p[0] < f.box[0]) f.box[0] = p[0];
    if (p[1] < f.box[1]) f.box[1] = p[1];
    if (p[0] > f.box[2]) f.box[2] = p[0];
    if (p[1] > f.box[3]) f.box[3] = p[1];
  }
  const list = f.quadList && f.quadList.length === quads.length ? f.quadList : (f.quadList = quads.map(() => [new Float64Array(0), 0]));
  for (let qi = 0; qi < quads.length; qi++) {
    const q = quads[qi];
    let entry = list[qi];
    if (entry[0].length < 2 * q.length) entry[0] = new Float64Array(2 * q.length);
    const buf = entry[0];
    for (let i = 0; i < q.length; i++) {
      buf[2 * i] = q[i][0];
      buf[2 * i + 1] = q[i][1];
    }
    entry[1] = q.length;
  }
  f.quadN = quads.length;
  f.quads = f.quadList;
  f.hullN = 0;
}

function t3Emit(run, cam, out) {
  const f = out ?? t3Frame(run);
  if (t3Arena.on) {
    t3Arena.f = f;
    f.arenaN = 0;
  }
  if (run.kind === "prism") t3EmitPrism(run, cam, f);
  else if (run.kind === "round") t3EmitRound(run, cam, f);
  else if (run.kind === "ball") t3EmitBall(run, cam, f);
  else if (run.kind === "lathe") t3EmitLathe(run, cam, f);
  else if (run.kind === "tube") {
    t3EmitTube(run, cam, f);
    return f;
  } else if (run.kind === "fixed") {
    t3EmitFixed(run, cam, f);
    return f;
  } else if (run.kind === "item") {
    f.box[0] = run.itemBox[0];
    f.box[1] = run.itemBox[1];
    f.box[2] = run.itemBox[2];
    f.box[3] = run.itemBox[3];
    const count = run.itemHull.length / 2;
    const hull = t3Grow(f, "hull", 2 * Math.max(1, count), Float64Array);
    for (let i = 0; i < 2 * count; i++) hull[i] = run.itemHull[i];
    f.hullN = count;
    return f;
  }
  t3EmitPlanes(run, cam, f);
  t3EmitDots(run, cam, f);
  return f;
}

function t3Overlap(a, b, pad) {
  return a[0] < b[2] - pad && b[0] < a[2] - pad && a[1] < b[3] - pad && b[1] < a[3] - pad;
}

function t3Apart(A, an, B, bn, pad) {
  for (let pass = 0; pass < 2; pass++) {
    const P = pass ? B : A;
    const pn = pass ? bn : an;
    for (let i = 0; i < pn; i++) {
      const j = (i + 1) % pn;
      const nx = P[2 * j + 1] - P[2 * i + 1];
      const ny = P[2 * i] - P[2 * j];
      const l = Math.hypot(nx, ny);
      if (l < 1e-9) continue;
      let a0 = Infinity;
      let a1 = -Infinity;
      for (let q = 0; q < an; q++) {
        const v = (A[2 * q] * nx + A[2 * q + 1] * ny) / l;
        if (v < a0) a0 = v;
        if (v > a1) a1 = v;
      }
      let b0 = Infinity;
      let b1 = -Infinity;
      for (let q = 0; q < bn; q++) {
        const v = (B[2 * q] * nx + B[2 * q + 1] * ny) / l;
        if (v < b0) b0 = v;
        if (v > b1) b1 = v;
      }
      if (a1 < b0 + pad || b1 < a0 + pad) return true;
    }
  }
  return false;
}

function t3Disjoint(fa, fb, pad) {
  const qa = fa.quadN ? fa.quads : null;
  const qb = fb.quadN ? fb.quads : null;
  if (!qa && !qb) return t3Apart(fa.hull, fa.hullN, fb.hull, fb.hullN, pad);
  const listA = qa ? fa.quadList : [[fa.hull, fa.hullN]];
  const listB = qb ? fb.quadList : [[fb.hull, fb.hullN]];
  for (const [A, an] of listA) for (const [B, bn] of listB) if (!t3Apart(A, an, B, bn, pad)) return false;
  return true;
}

function t3Support(cloud, R, t, dir, world) {
  let best = -Infinity;
  let at = 0;
  const n = cloud.length / 3;
  const dx = R[0] * dir[0] + R[3] * dir[1] + R[6] * dir[2];
  const dy = R[1] * dir[0] + R[4] * dir[1] + R[7] * dir[2];
  const dz = R[2] * dir[0] + R[5] * dir[1] + R[8] * dir[2];
  for (let i = 0; i < n; i++) {
    const v = cloud[3 * i] * dx + cloud[3 * i + 1] * dy + cloud[3 * i + 2] * dz;
    if (v > best) {
      best = v;
      at = i;
    }
  }
  const x = cloud[3 * at];
  const y = cloud[3 * at + 1];
  const z = cloud[3 * at + 2];
  world[0] = R[0] * x + R[1] * y + R[2] * z + t[0];
  world[1] = R[3] * x + R[4] * y + R[5] * z + t[1];
  world[2] = R[6] * x + R[7] * y + R[8] * z + t[2];
  return best + dir[0] * t[0] + dir[1] * t[1] + dir[2] * t[2];
}

function t3Gjk(cloudA, camA, cloudB, camB, warm, rounds = 8, hints = null) {
  let w = warm && (warm[0] || warm[1] || warm[2]) ? [warm[0], warm[1], warm[2]] : null;
  const a = [0, 0, 0];
  const b = [0, 0, 0];
  if (!w) {
    t3Support(cloudA, camA.R, camA.t, [1, 0, 0], a);
    t3Support(cloudB, camB.R, camB.t, [-1, 0, 0], b);
    w = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
  }
  let margin = -Infinity;
  let best = w;
  const step = (limit) => {
    for (let it = 0; it < limit; it++) {
      const l = Math.hypot(w[0], w[1], w[2]);
      if (l < 1e-12) break;
      const n = [w[0] / l, w[1] / l, w[2] / l];
      const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
      const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
      const value = loB - hiA;
      if (value > margin) {
        margin = value;
        best = n;
      }
      const s = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
      const ds = [w[0] - s[0], w[1] - s[1], w[2] - s[2]];
      const dd = ds[0] * ds[0] + ds[1] * ds[1] + ds[2] * ds[2];
      if (dd < 1e-14) break;
      const tt = Math.max(0, Math.min(1, (w[0] * ds[0] + w[1] * ds[1] + w[2] * ds[2]) / dd));
      w = [w[0] - ds[0] * tt, w[1] - ds[1] * tt, w[2] - ds[2] * tt];
    }
  };
  step(rounds);
  if (margin < 0) step(24);
  if (margin < 0.05 && hints) {
    if (hints.centroids) {
      const [ca, cb] = hints.centroids;
      const wa = [camA.R[0] * ca[0] + camA.R[1] * ca[1] + camA.R[2] * ca[2] + camA.t[0], camA.R[3] * ca[0] + camA.R[4] * ca[1] + camA.R[5] * ca[2] + camA.t[1], camA.R[6] * ca[0] + camA.R[7] * ca[1] + camA.R[8] * ca[2] + camA.t[2]];
      const wb = [camB.R[0] * cb[0] + camB.R[1] * cb[1] + camB.R[2] * cb[2] + camB.t[0], camB.R[3] * cb[0] + camB.R[4] * cb[1] + camB.R[5] * cb[2] + camB.t[1], camB.R[6] * cb[0] + camB.R[7] * cb[1] + camB.R[8] * cb[2] + camB.t[2]];
      const n = t3Unit([wb[0] - wa[0], wb[1] - wa[1], wb[2] - wa[2]]);
      const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
      const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
      if (loB - hiA > margin) {
        margin = loB - hiA;
        best = n;
      }
    }
    for (const [axis, cam] of hints) {
      const R = cam.R;
      const w0 = [R[0] * axis[0] + R[1] * axis[1] + R[2] * axis[2], R[3] * axis[0] + R[4] * axis[1] + R[5] * axis[2], R[6] * axis[0] + R[7] * axis[1] + R[8] * axis[2]];
      for (const sign of [1, -1]) {
        const n = [w0[0] * sign, w0[1] * sign, w0[2] * sign];
        const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
        const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
        if (loB - hiA > margin) {
          margin = loB - hiA;
          best = n;
        }
      }
    }
  }
  const l = Math.hypot(best[0], best[1], best[2]) || 1;
  return { n: [best[0] / l, best[1] / l, best[2] / l], margin };
}

function t3Order(layer, frames, cams, previous, stats, scene) {
  const parts = layer.parts;
  const count = parts.length;
  const local = layer.local;
  const rank = layer._rank ?? (layer._rank = new Int32Array(count));
  if (previous) for (let at = 0; at < count; at++) rank[local.get(previous[at])] = at;
  else for (let at = 0; at < count; at++) rank[local.get(layer.rest[at])] = at;
  let groupEdges = 0;
  if (layer.groupPairs) for (const gp of layer.groupPairs) groupEdges += gp.a.length * gp.b.length;
  const maxEdges = layer.planar.length / 5 + (layer.framed ? layer.framed.length / 6 : 0) + groupEdges + layer.dynamic.length / 2 + (layer.free ? layer.free.length * count : 0) + 1;
  const head = layer._head ?? (layer._head = new Int32Array(count));
  const next = layer._next && layer._next.length >= maxEdges ? layer._next : (layer._next = new Int32Array(maxEdges));
  const to = layer._to && layer._to.length >= maxEdges ? layer._to : (layer._to = new Int32Array(maxEdges));
  const live = layer._live && layer._live.length >= maxEdges ? layer._live : (layer._live = new Uint8Array(maxEdges));
  const indeg = layer._indeg ?? (layer._indeg = new Int32Array(count));
  head.fill(-1);
  indeg.fill(0);
  let edges = 0;
  const addEdge = (back, front) => {
    to[edges] = front;
    next[edges] = head[back];
    live[edges] = 1;
    head[back] = edges;
    indeg[front]++;
    edges++;
  };
  const runs = scene ? scene.runs : null;
  const groupOf = (global) => (runs ? runs[global].group : -1);
  const planar = layer.planar;
  for (let p = 0; p < planar.length; p += 5) {
    const i = planar[p];
    const j = planar[p + 1];
    const fi = frames[i];
    const fj = frames[j];
    if (!t3Overlap(fi.box, fj.box, -0.05)) continue;
    const g = groupOf(i);
    const V = g >= 0 ? cams[g].V : cams.world.V;
    const s = planar[p + 2] * V[0] + planar[p + 3] * V[1] + planar[p + 4] * V[2];
    if (s < 1e-7 && s > -1e-7) continue;
    const li = local.get(i);
    const lj = local.get(j);
    if (s > 0) addEdge(li, lj);
    else addEdge(lj, li);
  }
  const framed = layer.framed;
  if (framed)
    for (let p = 0; p < framed.length; p += 6) {
      const i = framed[p];
      const j = framed[p + 1];
      if (!t3Overlap(frames[i].box, frames[j].box, -0.05)) continue;
      const g = framed[p + 2];
      const V = g >= 0 ? cams[g].V : cams.world.V;
      const s = framed[p + 3] * V[0] + framed[p + 4] * V[1] + framed[p + 5] * V[2];
      if (s < 1e-7 && s > -1e-7) continue;
      const li = local.get(i);
      const lj = local.get(j);
      if (s > 0) addEdge(li, lj);
      else addEdge(lj, li);
    }
  if (layer.groupPairs)
    for (const gp of layer.groupPairs) {
      const union = (list, box) => {
        box[0] = Infinity;
        box[1] = Infinity;
        box[2] = -Infinity;
        box[3] = -Infinity;
        for (let q = 0; q < list.length; q++) {
          const b = frames[list[q]].box;
          if (b[0] < box[0]) box[0] = b[0];
          if (b[1] < box[1]) box[1] = b[1];
          if (b[2] > box[2]) box[2] = b[2];
          if (b[3] > box[3]) box[3] = b[3];
        }
      };
      union(gp.a, gp.boxA);
      union(gp.b, gp.boxB);
      if (!t3Overlap(gp.boxA, gp.boxB, -0.05)) continue;
      const V = gp.frame >= 0 ? cams[gp.frame].V : cams.world.V;
      const s = gp.n[0] * V[0] + gp.n[1] * V[1] + gp.n[2] * V[2];
      if (s < 1e-7 && s > -1e-7) continue;
      for (let x = 0; x < gp.a.length; x++) {
        const i = gp.a[x];
        const bi = frames[i].box;
        if (!t3Overlap(bi, gp.boxB, -0.05)) continue;
        for (let y = 0; y < gp.b.length; y++) {
          const j = gp.b[y];
          if (!t3Overlap(bi, frames[j].box, -0.05)) continue;
          if (s > 0) addEdge(local.get(i), local.get(j));
          else addEdge(local.get(j), local.get(i));
        }
      }
    }
  const dynamic = layer.dynamic;
  let unseparated = 0;
  let solved = 0;
  const rel = layer.rel ?? (layer.rel = new Map());
  const cross = (i, j) => {
    const ri = runs[i];
    const rj = runs[j];
    const ci = ri.group >= 0 ? cams[ri.group] : cams.world;
    const cj = rj.group >= 0 ? cams[rj.group] : cams.world;
    const A = ci.R;
    const B = cj.R;
    const dx = cj.t[0] - ci.t[0];
    const dy = cj.t[1] - ci.t[1];
    const dz = cj.t[2] - ci.t[2];
    const key = i * 65536 + j;
    let memo = rel.get(key);
    if (!memo) {
      memo = { pose: new Float64Array(12), n: [0, 0, 0], margin: -Infinity, fresh: false, vi: -1, vj: -1 };
      rel.set(key, memo);
    }
    const pose = memo.pose;
    const vi = ri.version ?? 0;
    const vj = rj.version ?? 0;
    let same = memo.fresh && memo.vi === vi && memo.vj === vj;
    memo.vi = vi;
    memo.vj = vj;
    let at = 0;
    for (let c = 0; c < 3; c++)
      for (let r = 0; r < 3; r++) {
        const v = A[c] * B[r] + A[3 + c] * B[3 + r] + A[6 + c] * B[6 + r];
        if (same && Math.abs(pose[at] - v) > 1e-12) same = false;
        pose[at++] = v;
      }
    for (let c = 0; c < 3; c++) {
      const v = A[c] * dx + A[3 + c] * dy + A[6 + c] * dz;
      if (same && Math.abs(pose[at] - v) > 1e-9) same = false;
      pose[at++] = v;
    }
    let nx;
    let ny;
    let nz;
    if (same) {
      const n = memo.n;
      nx = A[0] * n[0] + A[1] * n[1] + A[2] * n[2];
      ny = A[3] * n[0] + A[4] * n[1] + A[5] * n[2];
      nz = A[6] * n[0] + A[7] * n[1] + A[8] * n[2];
    } else {
      const n = memo.n;
      const warm = memo.fresh ? [A[0] * n[0] + A[1] * n[1] + A[2] * n[2], A[3] * n[0] + A[4] * n[1] + A[5] * n[2], A[6] * n[0] + A[7] * n[1] + A[8] * n[2]] : null;
      const hints = [...ri.axes.map((axis) => [axis, ci]), ...rj.axes.map((axis) => [axis, cj]), [[0, 0, 1], cams.world]];
      hints.centroids = [ri.centroid, rj.centroid];
      const hit = t3Gjk(ri.cloud, ci, rj.cloud, cj, warm, 8, hints);
      solved++;
      nx = hit.n[0];
      ny = hit.n[1];
      nz = hit.n[2];
      n[0] = A[0] * nx + A[3] * ny + A[6] * nz;
      n[1] = A[1] * nx + A[4] * ny + A[7] * nz;
      n[2] = A[2] * nx + A[5] * ny + A[8] * nz;
      memo.margin = hit.margin;
      memo.fresh = true;
    }
    if (memo.margin < -0.08) unseparated++;
    const V = cams.world.V;
    return nx * V[0] + ny * V[1] + nz * V[2];
  };
  for (let p = 0; p < dynamic.length; p += 2) {
    const i = dynamic[p];
    const j = dynamic[p + 1];
    if (!t3Overlap(frames[i].box, frames[j].box, -0.05)) continue;
    const s = cross(i, j);
    if (s < 1e-7 && s > -1e-7) continue;
    const li = local.get(i);
    const lj = local.get(j);
    if (s > 0) addEdge(li, lj);
    else addEdge(lj, li);
  }
  const loose = layer.free;
  if (loose && loose.length) {
    const isLoose = layer.looseAt;
    for (let a = 0; a < loose.length; a++) {
      const i = loose[a];
      const gi = runs[i].group;
      const bi = frames[i].box;
      for (let b = 0; b < count; b++) {
        const j = parts[b];
        if (j === i || runs[j].group === gi) continue;
        if (isLoose[b] && j < i) continue;
        if (!t3Overlap(bi, frames[j].box, -0.05)) continue;
        const s = cross(i, j);
        if (s < 1e-7 && s > -1e-7) continue;
        const li = local.get(i);
        if (s > 0) addEdge(li, b);
        else addEdge(b, li);
      }
    }
  }
  const heap = layer._heap ?? (layer._heap = new Int32Array(count));
  const done = layer._done ?? (layer._done = new Uint8Array(count));
  done.fill(0);
  let size = 0;
  const less = (a, b) => rank[a] < rank[b];
  const push = (v) => {
    let i = size++;
    heap[i] = v;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (!less(heap[i], heap[p])) break;
      const tmp = heap[i];
      heap[i] = heap[p];
      heap[p] = tmp;
      i = p;
    }
  };
  const pop = () => {
    const top = heap[0];
    heap[0] = heap[--size];
    let i = 0;
    for (;;) {
      const l = 2 * i + 1;
      const r = l + 1;
      let s = i;
      if (l < size && less(heap[l], heap[s])) s = l;
      if (r < size && less(heap[r], heap[s])) s = r;
      if (s === i) break;
      const tmp = heap[i];
      heap[i] = heap[s];
      heap[s] = tmp;
      i = s;
    }
    return top;
  };
  for (let v = 0; v < count; v++) if (indeg[v] === 0) push(v);
  const out = new Int32Array(count);
  let placed = 0;
  let forced = 0;
  let refined = 0;
  let tested = false;
  const mark = layer._mark && layer._mark.length >= count ? layer._mark : (layer._mark = new Uint8Array(count));
  const via = layer._via && layer._via.length >= count ? layer._via : (layer._via = new Int32Array(count));
  const iter = layer._iter && layer._iter.length >= count ? layer._iter : (layer._iter = new Int32Array(count));
  const stack = layer._stack && layer._stack.length >= count ? layer._stack : (layer._stack = new Int32Array(count));
  const checked = layer._checked && layer._checked.length >= maxEdges ? layer._checked : (layer._checked = new Uint8Array(maxEdges));
  checked.fill(0, 0, edges);
  const breakCycle = () => {
    mark.fill(0, 0, count);
    for (let root = 0; root < count; root++) {
      if (done[root] || mark[root]) continue;
      let top = 0;
      stack[top++] = root;
      mark[root] = 1;
      iter[root] = head[root];
      while (top) {
        const v = stack[top - 1];
        let e = iter[v];
        while (e >= 0 && (!live[e] || done[to[e]] || mark[to[e]] === 2)) e = next[e];
        if (e < 0) {
          mark[v] = 2;
          top--;
          continue;
        }
        iter[v] = next[e];
        const w = to[e];
        if (mark[w] === 1) {
          let dropped = false;
          let x = v;
          let edge = e;
          for (;;) {
            if (!checked[edge]) {
              checked[edge] = 1;
              const a = x;
              const b = to[edge];
              if (t3Disjoint(frames[parts[a]], frames[parts[b]], 0.1)) {
                live[edge] = 0;
                indeg[b]--;
                refined++;
                dropped = true;
                if (indeg[b] === 0) push(b);
              }
            }
            if (x === w) break;
            edge = via[x];
            let from = -1;
            for (let s = 0; s < top; s++) if (stack[s] === x) from = s > 0 ? stack[s - 1] : -1;
            if (from < 0) break;
            x = from;
          }
          return dropped ? 1 : 0;
        }
        via[w] = e;
        mark[w] = 1;
        iter[w] = head[w];
        stack[top++] = w;
      }
    }
    return -1;
  };
  while (placed < count) {
    if (size === 0) {
      let broken = 0;
      for (let tries = 0; tries < 64 && size === 0; tries++) {
        const result = breakCycle();
        if (result <= 0) break;
        broken++;
      }
      if (size) continue;
      void broken;
      void tested;
      let pick = -1;
      for (let v = 0; v < count; v++) if (!done[v] && (pick < 0 || indeg[v] < indeg[pick] || (indeg[v] === indeg[pick] && rank[v] < rank[pick]))) pick = v;
      forced++;
      indeg[pick] = 0;
      push(pick);
    }
    const v = pop();
    if (done[v]) continue;
    done[v] = 1;
    out[placed++] = parts[v];
    for (let e = head[v]; e >= 0; e = next[e]) {
      if (!live[e]) continue;
      const w = to[e];
      if (done[w]) continue;
      if (--indeg[w] === 0) push(w);
    }
  }
  if (stats) {
    stats.edges = (stats.edges ?? 0) + edges;
    stats.forced = (stats.forced ?? 0) + forced;
    stats.refined = (stats.refined ?? 0) + refined;
    stats.unseparated = (stats.unseparated ?? 0) + unseparated;
    stats.solved = (stats.solved ?? 0) + solved;
  }
  return out;
}

function t3Keep(values) {
  const n = values.length;
  const tails = [];
  const tailAt = [];
  const back = new Int32Array(n).fill(-1);
  for (let i = 0; i < n; i++) {
    let lo = 0;
    let hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (tails[mid] < values[i]) lo = mid + 1;
      else hi = mid;
    }
    tails[lo] = values[i];
    tailAt[lo] = i;
    back[i] = lo > 0 ? tailAt[lo - 1] : -1;
  }
  const keep = new Uint8Array(n);
  let k = tails.length ? tailAt[tails.length - 1] : -1;
  while (k >= 0) {
    keep[k] = 1;
    k = back[k];
  }
  return keep;
}

function t3Cams(scene, values) {
  const poses = t3Poses(scene.groups, values);
  const cams = poses.map((pose) => t3Camera(scene.P, pose));
  cams.world = t3Camera(scene.P, null);
  cams.poses = poses;
  return cams;
}

function t3Mount(stage, data, { onHold = null, onFrame = null, follow = null, painter = "svg", numeric = painter === "canvas", canvasLayers = null, shift = false } = {}) {
  const canvasSet = painter === "canvas" ? new Set(canvasLayers ?? data.layers.map((layer) => layer.name)) : new Set();
  const dom = canvasSet.size < data.layers.length;
  const domLayer = data.layers.map((layer) => !canvasSet.has(layer.name));
  const layerEls = new Map([...stage.querySelectorAll("svg[data-live]")].map((svg) => [svg.dataset.live, svg]));
  const first = layerEls.values().next().value;
  const viewWidth = first && first.viewBox && first.viewBox.baseVal ? first.viewBox.baseVal.width : 0;
  const unitOf = () => {
    const width = first ? first.getBoundingClientRect().width : 0;
    return viewWidth && width ? Math.round((width / viewWidth) * 100) / 100 : 1;
  };
  let unit = unitOf();
  let scene = t3Prepare(data, { unit });
  const runs = () => scene.runs;
  const count = data.parts.length;
  const nodes = new Array(count);
  const bound = new Array(count);
  const cache = new Array(count);
  const containers = new Map();
  for (const [name, svg] of layerEls) containers.set(name, svg.querySelector(".iso-parts") ?? svg);
  for (const run of scene.runs) {
    const layer = data.layers[run.layer];
    const container = containers.get(layer.name);
    const node = container ? container.querySelector(`[data-p="${run.index}"]`) : null;
    nodes[run.index] = node;
    const map = {};
    if (node) {
      map.move = node;
      node.querySelectorAll("[data-r]").forEach((el) => (map[el.getAttribute("data-r")] = el));
    }
    bound[run.index] = run.roles.map((role) => map[role.name] ?? null);
    cache[run.index] = run.roles.map(() => ({ d: null, q: null, b: null, f: null, w: null, t: null, o: null, cx: null, cy: null }));
  }
  const frames = scene.runs.map((run) => t3Frame(run));
  const values = Object.fromEntries(data.groups.map((g) => [g.name, data.values ? data.values[g.name] ?? 0 : 0]));
  let lastPoses = null;
  let cams = null;
  const orders = scene.layers.map((layer) => Int32Array.from(layer.rest));
  const changes = [];
  const stats = { ms: 0, writes: 0, moved: 0, forced: 0, refined: 0, unseparated: 0, emitted: 0 };
  let writes = 0;
  const write = (index) => {
    const run = scene.runs[index];
    const f = frames[index];
    const els = bound[index];
    const memo = cache[index];
    for (let r = 0; r < run.roles.length; r++) {
      const el = els[r];
      if (!el) continue;
      const role = run.roles[r];
      const c = memo[r];
      const type = role.type;
      if (type === "path" || type === "face" || type === "slot") {
        const d = f.d[r];
        if (c.d !== d) {
          el.setAttribute("d", d);
          c.d = d;
          writes++;
        }
        if (type === "face") {
          const q = f.q[r];
          if (c.q !== q) {
            const band = Math.min(3, Math.floor(q / T3_Q));
            const mix = (q - T3_Q * band) / T3_Q;
            if (c.b !== band) {
              el.setAttribute("data-b", String(band));
              c.b = band;
              writes++;
            }
            if (c.f !== mix) {
              if (mix) el.style.setProperty("--f", String(mix));
              else el.style.removeProperty("--f");
              c.f = mix;
              writes++;
            }
            c.q = q;
          }
          const w = f.w[r];
          if (c.w !== w) {
            if (w < 0) el.style.removeProperty("stroke-width");
            else el.style.strokeWidth = String(w);
            c.w = w;
            writes++;
          }
        } else if (type === "slot") {
          const o = f.o[r];
          if (c.o !== o) {
            el.setAttribute("opacity", String(Math.round(o * 1000) / 1000));
            c.o = o;
            writes++;
          }
        }
      } else if (type === "xform" || type === "self" || type === "plane") {
        const t = f.t[r];
        if (c.t !== t) {
          if (t) el.setAttribute("transform", t);
          else el.removeAttribute("transform");
          c.t = t;
          writes++;
        }
        if (type === "plane") {
          const o = f.o[r];
          if (c.o !== o) {
            el.setAttribute("opacity", String(o));
            c.o = o;
            writes++;
          }
        }
      } else if (type === "dot") {
        const x = Math.round(f.cx[r] * 100) / 100;
        const y = Math.round(f.cy[r] * 100) / 100;
        const o = f.o[r];
        if (c.cx !== x) {
          el.setAttribute("cx", String(x));
          c.cx = x;
          writes++;
        }
        if (c.cy !== y) {
          el.setAttribute("cy", String(y));
          c.cy = y;
          writes++;
        }
        if (c.o !== o) {
          el.setAttribute("fill-opacity", String(o));
          c.o = o;
          writes++;
        }
      }
    }
  };
  const samePose = (a, b) => {
    if (!a || !b) return false;
    for (let i = 0; i < 9; i++) if (a.R[i] !== b.R[i]) return false;
    return a.t[0] === b.t[0] && a.t[1] === b.t[1] && a.t[2] === b.t[2];
  };
  let lastUnit = unit;
  let moved = 0;
  let snapshotNext = false;
  const emittedList = [];
  const shiftOn = Boolean(shift);
  const emitAt = shiftOn ? new Array(count) : null;
  const shiftable = (run) => run.kind === "prism" || run.kind === "round" || run.kind === "ball" || run.kind === "lathe";
  const sameView = (base, cam) => {
    const m = cam.m;
    const b = base.m;
    for (let i = 0; i < 6; i++) if (m[i] !== b[i]) return false;
    return true;
  };
  const keepEmit = (run, cam, f) => {
    let base = emitAt[run.index];
    if (!base) base = emitAt[run.index] = { m: new Float64Array(6), ox: 0, oy: 0, box: new Float64Array(4), hull: new Float64Array(0), hullN: 0 };
    base.m.set(cam.m);
    base.ox = cam.ox;
    base.oy = cam.oy;
    base.box.set(f.box);
    if (base.hull.length < 2 * f.hullN) base.hull = new Float64Array(2 * f.hullN);
    for (let i = 0; i < 2 * f.hullN; i++) base.hull[i] = f.hull[i];
    base.hullN = f.hullN;
  };
  const shiftFrame = (f, base, dx, dy) => {
    f.box[0] = base.box[0] + dx;
    f.box[1] = base.box[1] + dy;
    f.box[2] = base.box[2] + dx;
    f.box[3] = base.box[3] + dy;
    const hull = t3Grow(f, "hull", 2 * base.hullN, Float64Array);
    for (let i = 0; i < base.hullN; i++) {
      hull[2 * i] = base.hull[2 * i] + dx;
      hull[2 * i + 1] = base.hull[2 * i + 1] + dy;
    }
    f.hullN = base.hullN;
    f.sx = dx;
    f.sy = dy;
  };
  const writeShift = (index, dx, dy) => {
    const node = nodes[index];
    if (!node) return;
    const text = dx || dy ? `translate(${Math.round(dx * 100) / 100} ${Math.round(dy * 100) / 100})` : "";
    if (node.__shift === text) return;
    if (text) node.setAttribute("transform", text);
    else node.removeAttribute("transform");
    node.__shift = text;
    writes++;
  };
  const render = () => {
    const t0 = performance.now();
    writes = 0;
    moved = 0;
    stats.orderMs = 0;
    t3Arena.on = false;
    const snapshotting = snapshotNext;
    snapshotNext = false;
    cams = t3Cams(scene, values);
    t3State.detail = detail;
    const poses = cams.poses;
    const fresh = lastUnit !== unit || !lastPoses;
    let emitted = 0;
    emittedList.length = 0;
    let shifted = 0;
    for (const run of scene.runs) {
      const g = run.group;
      const changed = fresh || (g >= 0 ? !samePose(poses[g], lastPoses[g]) : false);
      if (!changed) continue;
      const domRun = snapshotting || domLayer[run.layer];
      const cam = g >= 0 ? cams[g] : cams.world;
      const f = frames[run.index];
      const base = shiftOn && !fresh && !snapshotting ? emitAt[run.index] : null;
      if (base && sameView(base, cam)) {
        const dx = cam.ox - base.ox;
        const dy = cam.oy - base.oy;
        shiftFrame(f, base, dx, dy);
        if (domRun) writeShift(run.index, dx, dy);
        emittedList.push(run.index);
        shifted++;
        continue;
      }
      t3Arena.on = Boolean(numeric) && !domRun;
      t3Emit(run, cam, f);
      if (f.sx || f.sy) {
        f.sx = 0;
        f.sy = 0;
      }
      if (shiftOn && shiftable(run)) keepEmit(run, cam, f);
      if (domRun) {
        write(run.index);
        if (shiftOn && shiftable(run)) writeShift(run.index, 0, 0);
      }
      emittedList.push(run.index);
      emitted++;
    }
    stats.shifted = shifted;
    lastPoses = poses;
    lastUnit = unit;
    t3Arena.on = false;
    t3Arena.f = null;
    const t1 = performance.now();
    stats.emitMs = t1 - t0;
    const orderStats = { forced: 0, refined: 0, unseparated: 0, edges: 0 };
    scene.layers.forEach((layer, index) => {
      const previous = orders[index];
      const tOrder = performance.now();
      const order = t3Order(layer, frames, cams, previous, orderStats, scene);
      stats.orderMs += performance.now() - tOrder;
      let same = true;
      for (let j = 0; j < order.length; j++)
        if (order[j] !== previous[j]) {
          same = false;
          break;
        }
      if (snapshotting && !domLayer[index]) {
        const container = containers.get(layer.name);
        if (container) for (const global of order) if (nodes[global]) container.appendChild(nodes[global]);
        orders[index] = order;
        return;
      }
      if (same) return;
      if (!domLayer[index]) {
        orders[index] = order;
        return;
      }
      const container = containers.get(layer.name);
      const position = new Map();
      previous.forEach((global, at) => position.set(global, at));
      const keep = t3Keep(Array.from(order, (global) => position.get(global)));
      let after = null;
      const shifted = [];
      for (let j = order.length - 1; j >= 0; j--) {
        const node = nodes[order[j]];
        if (!keep[j] && node && container) {
          container.insertBefore(node, after);
          moved++;
          shifted.push(scene.runs[order[j]].name);
        }
        if (node) after = node;
      }
      orders[index] = order;
      if (shifted.length) changes.push({ layer: layer.name, moved: shifted });
    });
    stats.ms = performance.now() - t0;
    stats.writes = writes;
    stats.moved = moved;
    stats.forced = orderStats.forced;
    stats.refined = orderStats.refined;
    stats.unseparated = orderStats.unseparated;
    stats.emitted = emitted;
    t3State.detail = 1;
    if (onFrame) onFrame(controller);
    return { ...stats };
  };
  let detail = 1;
  const set = (next, options) => {
    if (options && options.detail !== undefined) {
      const value = Math.max(0, Math.min(1, options.detail));
      if (value !== detail) {
        detail = value;
        lastPoses = null;
      }
    }
    if (typeof next === "number") {
      const g = data.groups.find((group) => group.kind === "turn");
      if (g) values[g.name] = next;
    } else if (next) for (const [name, value] of Object.entries(next)) values[name] = value;
    if (follow) Object.assign(values, follow({ ...values }));
    return render();
  };
  const itemRuns = new Map();
  let unwrap = new Map();
  const angleAt = (clientX, clientY, z = 0, groupName) => {
    const svg = first;
    const box = svg.getBoundingClientRect();
    const vb = svg.viewBox.baseVal;
    const x = vb.x + ((clientX - box.left) / box.width) * vb.width;
    const y = vb.y + ((clientY - box.top) / box.height) * vb.height;
    const c = t3Camera(scene.P, null);
    const p = (x - c.ox) / c.k;
    const q = (y - c.oy + z * c.ce * c.k) / (c.se * c.k);
    const wx = p * c.sa + q * c.ca;
    const wy = -p * c.ca + q * c.sa;
    const group = data.groups.find((g) => g.name === groupName) ?? data.groups.find((g) => g.kind === "turn");
    const pivot = group ? group.origin : [0, 0, 0];
    const raw = (Math.atan2(wy - pivot[1], wx - pivot[0]) * 180) / Math.PI;
    const key = group ? group.name : "";
    const last = unwrap.get(key);
    let angle = raw;
    if (last !== undefined) angle = last + ((((raw - last) % 360) + 540) % 360) - 180;
    unwrap.set(key, angle);
    return angle;
  };
  const observer = typeof ResizeObserver === "function" && first
    ? new ResizeObserver(() => {
        if (!dom || !first.isConnected) return;
        const next = unitOf();
        if (Math.abs(next - unit) < 0.05) return;
        unit = next;
        scene = t3Prepare(data, { unit });
        render();
      })
    : null;
  if (observer) observer.observe(first);
  if (follow) Object.assign(values, follow({ ...values }));
  const controller = {
    set,
    render,
    painter,
    emitted: () => emittedList,
    snapshot: () => {
      snapshotNext = true;
      lastPoses = null;
      render();
      lastPoses = null;
      render();
      return [...containers.values()];
    },
    numeric: Boolean(numeric) && canvasSet.size > 0,
    canvasLayers: [...canvasSet],
    setUnit: (next) => {
      if (Math.abs(next - unit) < 1e-6) return null;
      unit = next;
      scene = t3Prepare(data, { unit });
      return render();
    },
    angleAt,
    get values() {
      return { ...values };
    },
    cover: (names, options) => t3Cover(controller, names, options),
    order: (name) => {
      const index = name === undefined ? 0 : scene.layers.findIndex((layer) => layer.name === name);
      return index < 0 ? [] : Array.from(orders[index], (global) => scene.runs[global].name);
    },
    orderIndex: (name) => {
      const index = name === undefined ? 0 : scene.layers.findIndex((layer) => layer.name === name);
      return index < 0 ? null : orders[index];
    },
    events: () => changes.splice(0, changes.length),
    stats: () => ({ ...stats }),
    frames: () => frames,
    cams: () => cams,
    scene: () => scene,
    pose: (name) => t3PoseOf(scene, cams, name),
    detach: (name) => {
      const index = scene.groups.findIndex((g) => g.name === name);
      const pose = t3PoseOf(scene, cams, name);
      const o = scene.groups[index].origin;
      return { world: true, R: pose.R, t: [pose.t[0] - o[0], pose.t[1] - o[1], pose.t[2] - o[2]] };
    },
    anchor: (name, point, direction) => t3Anchor(scene, cams, name, point, direction),
    item: (name, { cloud, box, hull }) => {
      const run = itemRuns.get(name) ?? scene.runs.find((r) => r.name === name);
      if (!run) return null;
      itemRuns.set(name, run);
      run.cloud = cloud instanceof Float64Array ? cloud : Float64Array.from(cloud);
      run.version = (run.version ?? 0) + 1;
      run.itemBox.set(box);
      run.itemHull = hull instanceof Float64Array ? hull : Float64Array.from(hull);
      const n = run.cloud.length / 3;
      run.centroid = [0, 0, 0];
      for (let i = 0; i < n; i++) for (let c = 0; c < 3; c++) run.centroid[c] += run.cloud[3 * i + c] / Math.max(1, n);
      t3Emit(run, null, frames[run.index]);
      return nodes[run.index];
    },
    node: (name) => {
      const run = scene.runs.find((r) => r.name === name);
      return run ? nodes[run.index] : null;
    },
    destroy: () => {
      if (observer) observer.disconnect();
      if (typeof window !== "undefined" && window.__isoTurn && window.__isoTurn.controller === controller) delete window.__isoTurn;
    },
  };
  const hold = () => {
    if (onHold) onHold();
  };
  if (typeof window !== "undefined") {
    window.__isoTurn = {
      controller,
      set(next) {
        hold();
        return set(next);
      },
      get() {
        hold();
        return { ...values };
      },
      order(name) {
        hold();
        return controller.order(name);
      },
      events() {
        hold();
        return controller.events();
      },
      stats() {
        hold();
        return controller.stats();
      },
      layers() {
        hold();
        return scene.layers.map((layer) => layer.name);
      },
    };
  }
  render();
  return controller;
}

function t3Enclose(points, edges) {
  const hull = t3Hull(points);
  const n = hull.length;
  if (n < 3) return [];
  let area = 0;
  for (let i = 0; i < n; i++) area += hull[i][0] * hull[(i + 1) % n][1] - hull[(i + 1) % n][0] * hull[i][1];
  const sign = area >= 0 ? 1 : -1;
  let lines = [];
  for (let i = 0; i < n; i++) {
    const p = hull[i];
    const q = hull[(i + 1) % n];
    const dx = q[0] - p[0];
    const dy = q[1] - p[1];
    const l = Math.hypot(dx, dy);
    if (l < 1e-9) continue;
    const nx = (sign * dy) / l;
    const ny = (-sign * dx) / l;
    lines.push([nx, ny, -(nx * p[0] + ny * p[1])]);
  }
  const meet = (A, B) => {
    const det = A[0] * B[1] - A[1] * B[0];
    if (Math.abs(det) < 1e-12) return null;
    return [(-A[2] * B[1] + B[2] * A[1]) / det, (-A[0] * B[2] + B[0] * A[2]) / det];
  };
  while (lines.length > edges) {
    let best = -1;
    let cost = Infinity;
    const m = lines.length;
    for (let i = 0; i < m; i++) {
      const prev = lines[(i - 1 + m) % m];
      const self = lines[i];
      const next = lines[(i + 1) % m];
      const turn = prev[0] * next[1] - prev[1] * next[0];
      if (sign * turn <= 1e-9) continue;
      const apex = meet(prev, next);
      const a = meet(prev, self);
      const b = meet(self, next);
      if (!apex || !a || !b) continue;
      const added = Math.abs((a[0] - apex[0]) * (b[1] - apex[1]) - (a[1] - apex[1]) * (b[0] - apex[0])) / 2;
      if (added < cost) {
        cost = added;
        best = i;
      }
    }
    if (best < 0) break;
    lines.splice(best, 1);
  }
  return lines;
}

function t3Cover(controller, names, { polygons = 6, edges = 10, pad = 0.3, layers = null } = {}) {
  const uCoverEdge = new Float32Array(polygons * edges * 3);
  const uCoverCount = new Float32Array(polygons);
  const scene = controller.scene();
  const frames = controller.frames();
  const sets = Array.isArray(names) ? (names.length && Array.isArray(names[0]) ? names : [names]) : [[names]];
  const candidates = [];
  const taken = new Set();
  for (const list of sets) {
    const targets = scene.runs.filter((run) => list.includes(run.name));
    if (!targets.length) continue;
    const box = [Infinity, Infinity, -Infinity, -Infinity];
    for (const run of targets) {
      const b = frames[run.index].box;
      box[0] = Math.min(box[0], b[0]);
      box[1] = Math.min(box[1], b[1]);
      box[2] = Math.max(box[2], b[2]);
      box[3] = Math.max(box[3], b[3]);
    }
    const home = targets[0].layer;
    scene.layers.forEach((layer, index) => {
      const order = controller.orderIndex(layer.name);
      if (!order) return;
      let from = 0;
      if (index === home) {
        for (let at = 0; at < order.length; at++) if (targets.some((run) => run.index === order[at])) from = at + 1;
      } else if (!(layers && layers.includes(layer.name)) || index < home) return;
      for (let at = from; at < order.length; at++) {
        const run = scene.runs[order[at]];
        if (list.includes(run.name) || taken.has(run.index)) continue;
        const f = frames[run.index];
        const b = f.box;
        if (!(b[0] < box[2] + pad && box[0] < b[2] + pad && b[1] < box[3] + pad && box[1] < b[3] + pad)) continue;
        if (!targets.some((target) => !t3Disjoint(frames[target.index], f, -pad))) continue;
        taken.add(run.index);
        const share = (Math.min(b[2], box[2]) - Math.max(b[0], box[0])) * (Math.min(b[3], box[3]) - Math.max(b[1], box[1]));
        candidates.push({ run, f, share });
      }
    });
  }
  candidates.sort((a, b) => b.share - a.share);
  const parts = [];
  let slot = 0;
  for (const { run, f } of candidates) {
    if (slot >= polygons) break;
    const points = [];
    if (f.quadN) for (const [buf, count] of f.quadList) for (let i = 0; i < count; i++) points.push([buf[2 * i], buf[2 * i + 1]]);
    else for (let i = 0; i < f.hullN; i++) points.push([f.hull[2 * i], f.hull[2 * i + 1]]);
    const lines = t3Enclose(points, edges);
    if (!lines.length) continue;
    lines.forEach((line, e) => {
      const at = (slot * edges + e) * 3;
      uCoverEdge[at] = line[0];
      uCoverEdge[at + 1] = line[1];
      uCoverEdge[at + 2] = line[2] - pad;
    });
    uCoverCount[slot] = lines.length;
    parts.push(run.name);
    slot++;
  }
  return { uCoverEdge, uCoverCount, dropped: Math.max(0, candidates.length - slot), parts };
}

function t3PoseOf(scene, cams, name) {
  const index = scene.groups.findIndex((g) => g.name === name);
  const cam = index >= 0 && cams ? cams[index] : null;
  return { R: cam ? Array.from(cam.R) : Array.from(T3_EYE), t: cam ? [cam.t[0], cam.t[1], cam.t[2]] : [0, 0, 0] };
}

function t3Anchor(scene, cams, name, point, direction) {
  const index = name === null || name === undefined ? -1 : scene.groups.findIndex((g) => g.name === name);
  const cam = index >= 0 ? cams[index] : cams.world;
  const o = index >= 0 ? scene.groups[index].origin : [0, 0, 0];
  const R = cam.R;
  const q = [point[0] - o[0], point[1] - o[1], point[2] - o[2]];
  const world = [R[0] * q[0] + R[1] * q[1] + R[2] * q[2] + cam.t[0], R[3] * q[0] + R[4] * q[1] + R[5] * q[2] + cam.t[1], R[6] * q[0] + R[7] * q[1] + R[8] * q[2] + cam.t[2]];
  const w = cams.world;
  const screen = [w.ox + w.m[0] * world[0] + w.m[1] * world[1] + w.m[2] * world[2], w.oy + w.m[3] * world[0] + w.m[4] * world[1] + w.m[5] * world[2]];
  const out = { world, screen };
  if (direction) out.direction = [R[0] * direction[0] + R[1] * direction[1] + R[2] * direction[2], R[3] * direction[0] + R[4] * direction[1] + R[5] * direction[2], R[6] * direction[0] + R[7] * direction[1] + R[8] * direction[2]];
  return out;
}

function t3FloorOf(scene, name, R = T3_EYE) {
  const index = scene.groups.findIndex((g) => g.name === name);
  let low = Infinity;
  for (const run of scene.runs) {
    if (run.group !== index) continue;
    const c = run.cloud;
    for (let i = 0; i < c.length; i += 3) {
      const z = R[6] * c[i] + R[7] * c[i + 1] + R[8] * c[i + 2];
      if (z < low) low = z;
    }
  }
  return low;
}

function t3Compose(A, B) {
  return Array.from(t3Mul(A, B));
}

function t3PoseUniforms(controller, group, prefix = "uGroup") {
  const scene = controller.scene();
  const index = scene.groups.findIndex((g) => g.name === group);
  const cams = controller.cams();
  const cam = index >= 0 && cams ? cams[index] : null;
  const R = cam ? cam.R : T3_EYE;
  const t = cam ? cam.t : [0, 0, 0];
  const o = index >= 0 ? scene.groups[index].origin : [0, 0, 0];
  const T = [t[0] - (R[0] * o[0] + R[1] * o[1] + R[2] * o[2]), t[1] - (R[3] * o[0] + R[4] * o[1] + R[5] * o[2]), t[2] - (R[6] * o[0] + R[7] * o[1] + R[8] * o[2])];
  return { [`${prefix}X`]: [R[0], R[3], R[6]], [`${prefix}Y`]: [R[1], R[4], R[7]], [`${prefix}Z`]: [R[2], R[5], R[8]], [`${prefix}T`]: T };
}

const T3_MAT_KEYS = ["paper", "face", "top", "shade-0", "shade-1", "shade-2", "shade-3", "lit-top", "lit-shade", "hi", "mid", "lo", "faint", "dot", "lit"];
const T3_MAT_TUBE = ["body", "shine", "shade", "edge"];

const T3_MATERIALS = {
  gold: {
    light: ["#d3bb88", "#e4d2a8", "#f1e5c6", "#a1844f", "#bc9f69", "#d3bb88", "#e4d2a8", "#f8efd8", "#dfcb9c", "#4b3920", "#86693f", "#a98c5b", "#c1a676", "#4a381f", "#2a1d0c", "#cdb07a", "#f3e8cc", "#8e7044", "#4b3920"],
    dark: ["#3f3521", "#4b3f28", "#5a4c31", "#2b2416", "#352c1b", "#3f3521", "#4b3f28", "#6a5a3b", "#4f4329", "#e6d2a3", "#8a7550", "#5e4e33", "#463a26", "#e6d2a3", "#fff3d6", "#4a3e27", "#8c7853", "#251f13", "#e6d2a3"],
  },
  chrome: {
    light: ["#8e959c", "#c3c8cd", "#f7f8f9", "#a2a9b0", "#6f767d", "#454b52", "#cacfd4", "#ffffff", "#bcc2c8", "#202428", "#585e65", "#878d94", "#a9afb5", "#2a2e33", "#000000", "#8b9299", "#fbfcfd", "#3c4248", "#202428"],
    dark: ["#2a2e32", "#4a5056", "#6e757c", "#2e3236", "#24272b", "#1a1d20", "#4a5056", "#848b92", "#3a3f44", "#dfe3e7", "#7c838a", "#4d5359", "#363b40", "#e4e7ea", "#ffffff", "#34393e", "#9aa1a8", "#16191b", "#dfe3e7"],
  },
  steel: {
    light: ["#b8bdc1", "#cdd1d4", "#e5e8ea", "#8f959b", "#a4aaaf", "#b8bdc1", "#cdd1d4", "#f1f3f4", "#c6cacd", "#2b2f33", "#6b7177", "#959ba1", "#aeb3b8", "#33373b", "#000000", "#b3b8bd", "#eef0f2", "#7d8389", "#2b2f33"],
    dark: ["#2c3034", "#34393d", "#3e4348", "#1f2225", "#25292c", "#2c3034", "#34393d", "#4a5056", "#33383c", "#d0d4d8", "#727980", "#4a5056", "#33383d", "#d8dce0", "#ffffff", "#30353a", "#5c6369", "#1a1d20", "#d0d4d8"],
  },
  gunmetal: {
    light: ["#444a51", "#50575e", "#5f666e", "#2f3439", "#393f45", "#444a51", "#50575e", "#6d757d", "#4b5258", "#0f1113", "#1d2125", "#272b30", "#30353a", "#b0b5ba", "#000000", "#3d4349", "#6e757c", "#24282c", "#0f1113"],
    dark: ["#1d2125", "#22272b", "#2a2f34", "#15181b", "#191c20", "#1d2125", "#22272b", "#323840", "#20252a", "#a9b0b7", "#5c636a", "#3a4046", "#2a2f34", "#b8bec4", "#ffffff", "#1f2327", "#3a4046", "#111316", "#a9b0b7"],
  },
  rubber: {
    light: ["#2a2b2e", "#34363a", "#46484d", "#1b1c1e", "#222326", "#2a2b2e", "#34363a", "#55575c", "#2f3134", "#070708", "#111214", "#18191b", "#1f2023", "#6a6c70", "#000000", "#27282b", "#5b5d62", "#121314", "#050506"],
    dark: ["#121213", "#161618", "#1c1c1f", "#0c0c0d", "#0f0f10", "#121213", "#161618", "#232327", "#141416", "#8c8e92", "#4a4c50", "#2c2d30", "#1f2023", "#9a9ca0", "#ffffff", "#131315", "#2b2c30", "#0a0a0b", "#8c8e92"],
  },
  brass: {
    light: ["#bd9646", "#d2ad5b", "#e6c87e", "#8a6a2c", "#a47f38", "#bd9646", "#d2ad5b", "#f0d796", "#c8a252", "#3f2e0e", "#765a24", "#9a7a3c", "#b08c46", "#3a2a0c", "#1f1606", "#b8913f", "#efd795", "#7d5f26", "#3f2e0e"],
    dark: ["#3d3017", "#49391c", "#574523", "#2a210f", "#332813", "#3d3017", "#49391c", "#66522b", "#45371b", "#e9cf8a", "#8e7438", "#5e4c25", "#44371c", "#e9cf8a", "#fff0c8", "#433419", "#8a6f36", "#21190b", "#e9cf8a"],
  },
  copper: {
    light: ["#b3633e", "#c9784f", "#df9670", "#7c4128", "#9a5132", "#b3633e", "#c9784f", "#eaa985", "#be6d46", "#3a1a0c", "#6e3820", "#925037", "#a65d3f", "#3a1a0c", "#1c0b04", "#ad5f3b", "#f0b896", "#6e3720", "#3a1a0c"],
    dark: ["#3d1f13", "#492617", "#582e1c", "#2a160d", "#331a10", "#3d1f13", "#492617", "#673722", "#432215", "#eaa985", "#8f5236", "#5f3624", "#45271a", "#eaa985", "#ffe0cc", "#42221a", "#8a4c31", "#1f0f08", "#eaa985"],
  },
};

function t3MaterialCss() {
  const rules = [];
  const block = (values) => T3_MAT_KEYS.map((key, i) => `--anatomy-${key}:${values[i]}`).concat(T3_MAT_TUBE.map((key, i) => `--mat-${key}:${values[T3_MAT_KEYS.length + i]}`)).join(";");
  for (const [name, { light, dark }] of Object.entries(T3_MATERIALS)) {
    rules.push(`[data-mat="${name}"]{${block(dark)}}`);
    rules.push(`.iso[data-theme="light"] [data-mat="${name}"]{${block(light)}}`);
    rules.push(`.iso[data-theme="dark"] [data-mat="${name}"]{${block(dark)}}`);
  }
  rules.push("[data-mat] .tb-body{fill:var(--mat-body)}");
  rules.push("[data-mat] .tb-shine{fill:var(--mat-shine)}");
  rules.push("[data-mat] .tb-shade{fill:var(--mat-shade)}");
  rules.push("[data-mat] .tb-edge{stroke:var(--mat-edge)}");
  return rules.join("\n");
}

function t3Css() {
  const rules = [];
  const ramp = "--tn-0:var(--anatomy-shade-0);--tn-1:var(--anatomy-shade-1);--tn-2:var(--anatomy-shade-2);--tn-3:var(--anatomy-shade-3);--tn-4:var(--anatomy-top)";
  rules.push(`.iso-live{${ramp}}`);
  rules.push(`.iso-live [data-mat]{${ramp}}`);
  rules.push(".iso-live [data-lit]{--tn-0:var(--anatomy-lit-shade);--tn-1:var(--anatomy-lit-shade);--tn-2:var(--anatomy-lit-shade);--tn-3:var(--anatomy-lit-shade);--tn-4:var(--anatomy-lit-top)}");
  const colour = "color-mix(in srgb,var(--ta) calc(100% - var(--f,0) * 100%),var(--tb))";
  rules.push(`.iso-tn{stroke-width:.4;stroke-linejoin:round;--ta:var(--tn-0);--tb:var(--tn-1);fill:${colour};stroke:${colour}}`);
  for (let i = 1; i < 4; i++) rules.push(`.iso-tn[data-b="${i}"]{--ta:var(--tn-${i});--tb:var(--tn-${i + 1})}`);
  rules.push(t3MaterialCss());
  for (const hue of ["red", "green", "blue"]) rules.push(`.tb[data-hue="${hue}"]{--tb-ink:var(--anatomy-${hue})}`);
  rules.push(".tb[data-hue] .tb-body{fill:color-mix(in srgb,var(--tb-ink) 54%,var(--anatomy-shade-2))}");
  rules.push(".tb[data-hue] .tb-shine{fill:color-mix(in srgb,var(--tb-ink) 16%,var(--anatomy-top))}");
  rules.push(".tb[data-hue] .tb-shade{fill:color-mix(in srgb,var(--tb-ink) 30%,var(--anatomy-hi))}");
  rules.push(".tb[data-hue] .tb-edge{stroke:color-mix(in srgb,var(--tb-ink) 38%,var(--anatomy-hi))}");
  rules.push(".tb[data-hue][data-mat] .tb-body{fill:color-mix(in srgb,var(--tb-ink) 62%,var(--mat-body))}");
  rules.push(".tb[data-hue][data-mat] .tb-shine{fill:color-mix(in srgb,var(--tb-ink) 22%,var(--mat-shine))}");
  rules.push(".tb[data-hue][data-mat] .tb-shade{fill:color-mix(in srgb,var(--tb-ink) 46%,var(--mat-shade))}");
  rules.push(".tb[data-hue][data-mat] .tb-edge{stroke:color-mix(in srgb,var(--tb-ink) 30%,var(--mat-edge))}");
  rules.push(".iso-turn{position:relative}");
  rules.push(".iso-turn>.iso-layer{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}");
  return rules.join("\n");
}

export const TURN = { materials: Object.keys(T3_MATERIALS), materialCss: t3MaterialCss, rotation: (axis, degrees) => Array.from(t3Rot(axis, degrees)), quat: (q) => Array.from(t3Quat(q)), compose: t3Compose, floorOf: t3FloorOf, anchor: t3Anchor, camera: t3Camera, poses: t3PosesMap, prepare: t3Prepare, emit: t3Emit, order: t3Order, mount: t3Mount, cover: t3Cover, poseUniforms: t3PoseUniforms, css: t3Css, frame: t3Frame, cams: t3Cams, gjk: t3Gjk, hintsOf: (ri, ci, rj, cj, world) => Object.assign([...ri.axes.map((axis) => [axis, ci]), ...rj.axes.map((axis) => [axis, cj]), [[0, 0, 1], world]], { centroids: [ri.centroid, rj.centroid] }), disjoint: t3Disjoint, levels: T3_LEVELS, q: T3_Q, exact: (on) => { t3State.exact = Boolean(on); } };
export const TURN_CSS = t3Css();
```

## kit/turn.mjs

```js
const T3_ROOT = Math.sqrt(1.6);
const T3_TAU = Math.PI * 2;
const T3_RAD = Math.PI / 180;
const T3_LB = [0, 0.2, 0.42, 0.62];
const T3_EB = [0.25, 0.5, 0.75];
const T3_SOFT = 0.035;
const T3_LEVELS = 32;
const T3_Q = 64;
const t3State = { detail: 1, exact: false };

function t3Num(v) {
  if (t3State.exact) return String(Math.round(v * 1e6) / 1e6);
  let t = Math.round(v * 100);
  if (t === 0) return "0";
  let sign = "";
  if (t < 0) {
    sign = "-";
    t = -t;
  }
  const whole = Math.floor(t / 100);
  const part = t - whole * 100;
  if (part === 0) return sign + whole;
  if (part < 10) return sign + whole + ".0" + part;
  return sign + whole + "." + (part % 10 === 0 ? part / 10 : part);
}

function t3Fix(v, scale) {
  const t = Math.round(v * scale) / scale;
  return t === 0 ? "0" : String(t);
}

function t3Path(buf, count, closed) {
  if (count < 2) return "";
  let s = "M" + t3Num(buf[0]) + " " + t3Num(buf[1]);
  for (let i = 1; i < count; i++) s += "L" + t3Num(buf[2 * i]) + " " + t3Num(buf[2 * i + 1]);
  return closed ? s + "Z" : s;
}

const t3Step = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

function t3Level(score, bands) {
  let u = 0;
  for (let i = 0; i < bands.length; i++) u += t3Step(bands[i] - T3_SOFT, bands[i] + T3_SOFT, score);
  return Math.round(u * T3_Q);
}

function t3Grow(f, name, size, Kind) {
  if (!f[name] || f[name].length < size) f[name] = new Kind(Math.max(size, f[name] ? f[name].length * 2 : 16));
  return f[name];
}

function t3Box(buf, count, box) {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (let i = 0; i < count; i++) {
    const x = buf[2 * i];
    const y = buf[2 * i + 1];
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  box[0] = x0;
  box[1] = y0;
  box[2] = x1;
  box[3] = y1;
}

function t3Unit(v) {
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
  return [v[0] / l, v[1] / l, v[2] / l];
}

function t3Rot(axis, degrees) {
  const [x, y, z] = t3Unit(axis);
  const c = Math.cos(degrees * T3_RAD);
  const s = Math.sin(degrees * T3_RAD);
  const C = 1 - c;
  return new Float64Array([c + x * x * C, x * y * C - z * s, x * z * C + y * s, y * x * C + z * s, c + y * y * C, y * z * C - x * s, z * x * C - y * s, z * y * C + x * s, c + z * z * C]);
}

function t3Mul(A, B) {
  const out = new Float64Array(9);
  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];
  return out;
}

const t3Apply = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];

const T3_EYE = new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);

function t3Camera(P, pose) {
  const a = (P.azimuth ?? 45) * T3_RAD;
  const e = (P.elevation ?? 30) * T3_RAD;
  const k = P.scale * T3_ROOT;
  const sa = Math.sin(a);
  const ca = Math.cos(a);
  const se = Math.sin(e);
  const ce = Math.cos(e);
  const cam = { m: new Float64Array(6), ox: 0, oy: 0, V: [0, 0, 0], L: [0, 0, 0], k, turn: 0, R: T3_EYE, t: [0, 0, 0], lx: 0, ly: 1, sa, ca, se, ce, origin: P.origin };
  const lw = Math.hypot(sa, ca, 1.2);
  if (pose && pose.R && !(pose.turn !== undefined && pose.root)) {
    const R = pose.R;
    const t = pose.t ?? [0, 0, 0];
    const r0 = [sa * k, -ca * k, 0];
    const r1 = [ca * se * k, sa * se * k, -ce * k];
    for (let c = 0; c < 3; c++) {
      cam.m[c] = r0[0] * R[c] + r0[1] * R[3 + c] + r0[2] * R[6 + c];
      cam.m[3 + c] = r1[0] * R[c] + r1[1] * R[3 + c] + r1[2] * R[6 + c];
    }
    cam.ox = P.origin[0] + r0[0] * t[0] + r0[1] * t[1] + r0[2] * t[2];
    cam.oy = P.origin[1] + r1[0] * t[0] + r1[1] * t[1] + r1[2] * t[2];
    const V = [ca * ce, sa * ce, se];
    const L = [-sa / lw, ca / lw, 1.2 / lw];
    for (let c = 0; c < 3; c++) {
      cam.V[c] = R[c] * V[0] + R[3 + c] * V[1] + R[6 + c] * V[2];
      cam.L[c] = R[c] * L[0] + R[3 + c] * L[1] + R[6 + c] * L[2];
    }
    cam.R = R;
    cam.t = t;
  } else {
    const turn = pose ? pose.turn ?? 0 : 0;
    const pivot = pose && pose.pivot ? pose.pivot : [0, 0];
    const b = a - turn * T3_RAD;
    const sb = Math.sin(b);
    const cb = Math.cos(b);
    cam.m[0] = sb * k;
    cam.m[1] = -cb * k;
    cam.m[2] = 0;
    cam.m[3] = cb * se * k;
    cam.m[4] = sb * se * k;
    cam.m[5] = -ce * k;
    cam.ox = P.origin[0] + (pivot[0] * sa - pivot[1] * ca) * k;
    cam.oy = P.origin[1] + (pivot[0] * ca + pivot[1] * sa) * se * k;
    cam.V = [cb * ce, sb * ce, se];
    cam.L = [-sb / lw, cb / lw, 1.2 / lw];
    cam.turn = turn;
    const c = Math.cos(turn * T3_RAD);
    const s = Math.sin(turn * T3_RAD);
    cam.R = new Float64Array([c, -s, 0, s, c, 0, 0, 0, 1]);
    cam.t = [pivot[0], pivot[1], 0];
  }
  const lh = Math.hypot(cam.L[0], cam.L[1]) || 1;
  cam.lx = cam.L[0] / lh;
  cam.ly = cam.L[1] / lh;
  return cam;
}

function t3Quat(q) {
  const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
  const x = q[0] / l;
  const y = q[1] / l;
  const z = q[2] / l;
  const w = q[3] / l;
  return new Float64Array([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]);
}

function t3FreeRot(v) {
  if (v.R) return v.R instanceof Float64Array ? v.R : Float64Array.from(v.R);
  if (v.q) return t3Quat(v.q);
  if (v.axis) return t3Rot(v.axis, v.angle ?? 0);
  return T3_EYE;
}

function t3Poses(groups, values) {
  const get = (name) => (values instanceof Map ? values.get(name) : values ? values[name] : undefined) ?? 0;
  const list = [];
  for (let index = 0; index < groups.length; index++) {
    const g = groups[index];
    const v = get(g.name);
    let parent = g.parent === undefined || g.parent === null || g.parent < 0 ? null : list[g.parent];
    let po = parent ? groups[g.parent].origin : [0, 0, 0];
    let R = T3_EYE;
    let t = [g.origin[0] - po[0], g.origin[1] - po[1], g.origin[2] - po[2]];
    if (g.kind === "turn") R = t3Rot([0, 0, 1], v);
    else if (g.kind === "hinge") R = t3Rot(g.axis, v);
    else if (g.kind === "slide") {
      const d = t3Unit(g.direction);
      t[0] += d[0] * v;
      t[1] += d[1] * v;
      t[2] += d[2] * v;
    } else if (g.kind === "free" && v && typeof v === "object") {
      R = t3FreeRot(v);
      if (v.world) {
        parent = null;
        po = [0, 0, 0];
        t = [g.origin[0], g.origin[1], g.origin[2]];
      }
      if (v.t) {
        t[0] += v.t[0];
        t[1] += v.t[1];
        t[2] += v.t[2];
      }
    }
    if (!parent) {
      const pose = { R, t, value: v };
      if (g.kind === "turn") Object.assign(pose, { turn: v, pivot: [g.origin[0], g.origin[1]], root: true });
      list.push(pose);
      continue;
    }
    const tw = t3Apply(parent.R, t);
    list.push({ R: t3Mul(parent.R, R), t: [tw[0] + parent.t[0], tw[1] + parent.t[1], tw[2] + parent.t[2]], value: v });
  }
  return list;
}

function t3PosesMap(groups, values) {
  const list = t3Poses(groups, values);
  return new Map(groups.map((g, index) => [g.name, list[index]]));
}

function t3Roles(part) {
  const roles = [];
  const spec = part.spec;
  const details = part.details ?? {};
  const add = (name, type, extra = {}) => roles.push({ name, type, ...extra });
  const tone = part.tone ?? "hi";
  const crease = part.crease ?? "faint";
  const fadedSlots = (prefix, count, cls, lineTone) => {
    for (let j = 0; j < count; j++) add(`${prefix}${j}`, "slot", { cls, tone: lineTone });
  };
  const planes = () => (part.planes ?? []).forEach((_, j) => add(`plane${j}`, "plane"));
  const dots = () => (part.dots ?? []).forEach((dot, j) => add(`c${j}`, "dot", { tone: dot.tone ?? "mid", size: dot.size ?? 0.5 }));
  const sheets = (top) => {
    for (let t = 0; t < 4; t++) add(`s${t}`, "path", { cls: "iso-shade", shade: t });
    if (top) add("s4", "path", { cls: "iso-top" });
  };
  const inner = () => {
    if ((details.seams && details.seams.length) || (details.ribs && details.ribs.seams)) add("inner", "path", { cls: "iso-line", tone: "faint" });
  };
  const ribs = () => {
    if (details.ribs) fadedSlots("r", spec.slots?.ribs ?? 6, "iso-line", details.ribs.tone ?? "lo");
  };
  if (part.kind === "prism") {
    add("fill", "path", { cls: "iso-fill" });
    const faces = spec.ring.length / 2 + (spec.up ? 0 : 2);
    for (let j = 0; j < faces; j++) add(`f${j}`, "face");
    if (spec.up) add("top", "xform", { cls: "iso-top" });
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    if (spec.bevel && crease !== "none") fadedSlots("v", spec.slots?.bevel ?? 6, "iso-line iso-bevel", crease);
    if (details.seams && details.seams.length) add("inner", "path", { cls: "iso-line", tone: "faint" });
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "round" || part.kind === "lathe") {
    add("fill", "path", { cls: "iso-fill" });
    sheets(true);
    const ends = part.kind === "round" ? spec.ends : spec.caps.map((cap) => (cap ? "flat" : "open"));
    ends.forEach((end, j) => end === "flat" && add(`k${j}`, "face"));
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    inner();
    ribs();
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "ball") {
    add("fill", "path", { cls: "iso-fill" });
    sheets(false);
    spec.flats.forEach((_, j) => add(`f${j}`, "face"));
    if (crease !== "none") add("crease", "path", { cls: "iso-line iso-crease", tone: crease });
    planes();
    add("edge", "path", { cls: "iso-line iso-edge", tone });
    dots();
  } else if (part.kind === "tube") {
    add("body", "path", { cls: "tb-body" });
    if (spec.wide) {
      add("shine", "path", { cls: "tb-shine" });
      add("shade", "path", { cls: "tb-shade" });
    }
    if (spec.rings) fadedSlots("r", spec.slots?.ribs ?? 6, "tb-ring", spec.rings.tone ?? "lo");
    add("edges", "path", { cls: "iso-line tb-edge", tone });
  } else if (part.kind === "fixed") {
    add("move", "self");
    ribs();
    planes();
    dots();
  }
  return roles;
}

function t3Planes(list) {
  return (list ?? []).map((plane) => ({ o: plane.o, u: plane.u, v: plane.v, n: plane.n, fade: plane.fade ?? [0.04, 0.3], billboard: Boolean(plane.billboard) }));
}

function t3Dots(list) {
  return (list ?? []).map((dot) => ({ p: dot.p, n: dot.n ?? null, size: dot.size ?? 0.5, fade: dot.fade ?? [0.04, 0.42] }));
}

function t3PreparePrism(run, part) {
  const spec = part.spec;
  const n = spec.ring.length / 2;
  run.n = n;
  run.up = Boolean(spec.up);
  const ring = Float64Array.from(spec.ring);
  run.ring = ring;
  const nx = new Float64Array(n);
  const ny = new Float64Array(n);
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n;
    const dx = ring[2 * j] - ring[2 * i];
    const dy = ring[2 * j + 1] - ring[2 * i + 1];
    const l = Math.hypot(dx, dy) || 1;
    nx[i] = dy / l;
    ny[i] = -dx / l;
  }
  run.nx = nx;
  run.ny = ny;
  const F = spec.up ? { o: [0, 0, 0], a: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] } : spec.F;
  run.F = F;
  run.s0 = spec.up ? spec.z0 : spec.s0;
  run.s1 = spec.up ? spec.z1 : spec.s1;
  const lo = new Float64Array(3 * n);
  const hi = new Float64Array(3 * n);
  const N3 = new Float64Array(3 * n);
  for (let i = 0; i < n; i++) {
    const x = ring[2 * i];
    const y = ring[2 * i + 1];
    for (let c = 0; c < 3; c++) {
      const base = F.o[c] + F.u[c] * x + F.v[c] * y;
      lo[3 * i + c] = base + F.a[c] * run.s0;
      hi[3 * i + c] = base + F.a[c] * run.s1;
      N3[3 * i + c] = F.u[c] * nx[i] + F.v[c] * ny[i];
    }
  }
  run.lo = lo;
  run.hi = hi;
  run.N3 = N3;
  run.sharp = new Uint8Array(n);
  for (let i = 0; i < n; i++) {
    const p = (i - 1 + n) % n;
    run.sharp[i] = nx[p] * nx[i] + ny[p] * ny[i] < Math.cos(0.35) ? 1 : 0;
  }
  if (spec.bevel) {
    const inset = new Float64Array(2 * n);
    for (let i = 0; i < n; i++) {
      const p = (i - 1 + n) % n;
      const mx = nx[p] + nx[i];
      const my = ny[p] + ny[i];
      const l = Math.hypot(mx, my) || 1;
      inset[2 * i] = ring[2 * i] - (mx / l) * spec.bevel;
      inset[2 * i + 1] = ring[2 * i + 1] - (my / l) * spec.bevel;
    }
    run.inset = inset;
  }
  run.seams = part.details && part.details.seams ? part.details.seams.slice() : null;
  run.cut = spec.cuts && spec.cuts.length ? spec.cuts : null;
}

function t3Axes(run, part) {
  if (run.kind === "ball") return run.flats.map((flat) => flat.n);
  if (run.kind === "tube") return run.tips.slice();
  if (run.kind === "prism") {
    const out = [run.F.a];
    for (let i = 0; i < run.n; i++) {
      const n = [run.N3[3 * i], run.N3[3 * i + 1], run.N3[3 * i + 2]];
      if (out.every((m) => Math.abs(m[0] * n[0] + m[1] * n[1] + m[2] * n[2]) < 0.9995)) out.push(n);
    }
    return out;
  }
  if (run.F) return [run.F.a];
  if (part.spec && part.spec.F) return [part.spec.F.a];
  return [];
}

function t3Cloud(run, part) {
  const out = [];
  const ring = (F, s, r, count) => {
    for (let j = 0; j < count; j++) {
      const c = Math.cos((j / count) * T3_TAU);
      const n = Math.sin((j / count) * T3_TAU);
      out.push(F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]), F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]), F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]));
    }
  };
  if (run.kind === "prism") {
    for (let i = 0; i < 3 * run.n; i++) out.push(run.lo[i]);
    for (let i = 0; i < 3 * run.n; i++) out.push(run.hi[i]);
  } else if (run.kind === "round") {
    ring(run.F, run.s0, run.r0, 24);
    ring(run.F, run.s1, run.r1, 24);
    for (const [end, s, r] of [[0, run.s0, run.r0], [1, run.s1, run.r1]]) {
      if (run.ends[end] !== "dome") continue;
      const sign = end ? 1 : -1;
      for (const b of [0.5, 1.0, 1.35]) ring(run.F, s + sign * r * Math.sin(b), r * Math.cos(b), 6);
      out.push(run.F.o[0] + run.F.a[0] * (s + sign * r), run.F.o[1] + run.F.a[1] * (s + sign * r), run.F.o[2] + run.F.a[2] * (s + sign * r));
    }
  } else if (run.kind === "ball") {
    const golden = Math.PI * (3 - Math.sqrt(5));
    for (let i = 0; i < 64; i++) {
      const y = 1 - (2 * (i + 0.5)) / 64;
      const rad = Math.sqrt(1 - y * y);
      const w = [Math.cos(golden * i) * rad, Math.sin(golden * i) * rad, y];
      const p = [run.c[0] + run.r * w[0], run.c[1] + run.r * w[1], run.c[2] + run.r * w[2]];
      let inside = true;
      for (const flat of run.flats) if ((p[0] - run.c[0]) * flat.n[0] + (p[1] - run.c[1]) * flat.n[1] + (p[2] - run.c[2]) * flat.n[2] > flat.d + 1e-9) inside = false;
      if (inside) out.push(p[0], p[1], p[2]);
    }
    for (const flat of run.flats)
      for (let j = 0; j < 12; j++) {
        const c = Math.cos((j / 12) * T3_TAU) * flat.rho;
        const n = Math.sin((j / 12) * T3_TAU) * flat.rho;
        out.push(run.c[0] + flat.n[0] * flat.d + c * flat.u[0] + n * flat.v[0], run.c[1] + flat.n[1] * flat.d + c * flat.u[1] + n * flat.v[1], run.c[2] + flat.n[2] * flat.d + c * flat.u[2] + n * flat.v[2]);
      }
  } else if (run.kind === "lathe") {
    const lv = run.levels;
    const step = Math.max(4, Math.floor(lv.length / 4 / 8)) * 4;
    for (let i = 0; i < lv.length; i += step) ring(run.F, lv[i], lv[i + 1], 8);
    ring(run.F, lv[lv.length - 4], lv[lv.length - 3], 8);
  } else if (run.kind === "tube") {
    const b = run.body;
    const stride = Math.max(1, Math.floor(b.count / 8));
    for (let i = 0; i < b.count; i += stride) {
      const T = t3Unit([b.D3[3 * i], b.D3[3 * i + 1], b.D3[3 * i + 2]]);
      const [u, v] = t3Basis(T);
      ring({ o: [b.P3[3 * i], b.P3[3 * i + 1], b.P3[3 * i + 2]], a: T, u, v }, 0, run.r, 8);
    }
  } else if (part.cloud) for (const v of part.cloud) out.push(v);
  return Float64Array.from(out);
}

function t3Unpack(value, grouped) {
  if (!value) return new Float64Array(0);
  if (typeof value !== "string") return Float64Array.from(value);
  const text = atob(value);
  const bytes = new Uint8Array(text.length);
  for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
  const view = new DataView(bytes.buffer);
  const size = grouped ? 10 : 8;
  const stride = grouped ? 6 : 5;
  const count = bytes.length / size;
  const out = new Float64Array(count * stride);
  for (let e = 0; e < count; e++) {
    let o = e * size;
    const at = e * stride;
    out[at] = view.getUint16(o);
    out[at + 1] = view.getUint16(o + 2);
    o += 4;
    if (grouped) {
      out[at + 2] = view.getUint16(o) - 1;
      o += 2;
    }
    let x = (view.getUint16(o) / 65535) * 2 - 1;
    let y = (view.getUint16(o + 2) / 65535) * 2 - 1;
    const z = 1 - Math.abs(x) - Math.abs(y);
    if (z < 0) {
      const ox = (1 - Math.abs(y)) * (x >= 0 ? 1 : -1);
      const oy = (1 - Math.abs(x)) * (y >= 0 ? 1 : -1);
      x = ox;
      y = oy;
    }
    const l = Math.hypot(x, y, z) || 1;
    out[at + stride - 3] = x / l;
    out[at + stride - 2] = y / l;
    out[at + stride - 1] = z / l;
  }
  return out;
}

function t3Prepare(data, { unit = 1 } = {}) {
  const P = data.P;
  const runs = data.parts.map((part, index) => {
    const run = { index, name: part.name, kind: part.kind, group: part.group ?? -1, layer: part.layer, part, roles: t3Roles(part), planes: t3Planes(part.planes), dots: t3Dots(part.dots), unit };
    run.role = Object.fromEntries(run.roles.map((role, j) => [role.name, j]));
    if (part.kind === "prism") t3PreparePrism(run, part);
    else if (part.kind === "round") t3PrepareRound(run, part, P, unit);
    else if (part.kind === "ball") t3PrepareBall(run, part, P, unit);
    else if (part.kind === "lathe") t3PrepareLathe(run, part, P, unit);
    else if (part.kind === "tube") t3PrepareTube(run, part, data);
    else if (part.kind === "fixed") {
      run.anchor = part.spec.anchor;
      run.home = part.spec.home;
      run.box0 = part.spec.box;
      run.hull0 = Float64Array.from(part.spec.hull);
      run.F = part.spec.F ?? null;
      run.ribs = part.details && part.details.ribs ? part.details.ribs : null;
    }
    run.slots = part.spec.slots ?? {};
    run.cloud = t3Cloud(run, part);
    run.axes = t3Axes(run, part);
    const cn = run.cloud.length / 3;
    run.centroid = [0, 0, 0];
    for (let i = 0; i < cn; i++) for (let c = 0; c < 3; c++) run.centroid[c] += run.cloud[3 * i + c] / Math.max(1, cn);
    return run;
  });
  const layers = data.layers.map((layer, index) => {
    const parts = Int32Array.from(layer.parts);
    const local = new Map();
    parts.forEach((global, at) => local.set(global, at));
    const planar = t3Unpack(data.planes && data.planes[layer.name], false);
    const dynamic = Int32Array.from((data.dynamic && data.dynamic[layer.name]) ?? []);
    const free = Int32Array.from((data.free && data.free[layer.name]) ?? []);
    const framed = t3Unpack(data.framed && data.framed[layer.name], true);
    const groupPairs = [];
    const raw = (data.groupPairs && data.groupPairs[layer.name]) ?? [];
    const members = new Map();
    for (const global of parts) {
      const g = runs[global].group;
      if (!members.has(g)) members.set(g, []);
      members.get(g).push(global);
    }
    for (let p = 0; p < raw.length; p += 6) groupPairs.push({ a: Int32Array.from(members.get(raw[p]) ?? []), b: Int32Array.from(members.get(raw[p + 1]) ?? []), frame: raw[p + 2], n: [raw[p + 3], raw[p + 4], raw[p + 5]], boxA: new Float64Array(4), boxB: new Float64Array(4) });
    const looseAt = new Uint8Array(parts.length);
    for (const global of free) looseAt[local.get(global)] = 1;
    return { index, name: layer.name, parts, local, rest: Int32Array.from(layer.rest ?? layer.parts), planar, framed, groupPairs, dynamic, free, looseAt, warm: new Map() };
  });
  return { P, data, unit, groups: data.groups, runs, layers };
}

function t3Frame(run) {
  const count = run.roles.length;
  return { d: new Array(count).fill(""), q: new Int32Array(count), w: new Float64Array(count).fill(-1), t: new Array(count).fill(""), o: new Float64Array(count).fill(1), box: new Float64Array(4), hull: new Float64Array(64), hullN: 0, cx: new Float64Array(count), cy: new Float64Array(count), quads: null, quadN: 0 };
}

const t3Buf = { a: new Float64Array(1024), b: new Float64Array(1024), c: new Float64Array(1024) };

function t3Scratch(name, size) {
  if (t3Buf[name].length < size) t3Buf[name] = new Float64Array(size * 2);
  return t3Buf[name];
}

function t3SetHull(f, buf, count) {
  const hull = t3Grow(f, "hull", 2 * count, Float64Array);
  for (let i = 0; i < 2 * count; i++) hull[i] = buf[i];
  f.hullN = count;
}

function t3Faded(f, run, prefix, lines, count) {
  if (!f.fade) f.fade = {};
  const detail = t3State.detail;
  if (detail < 1) for (let i = 0; i < count; i++) lines.alpha[i] *= detail;
  if (detail < 0.01) count = 0;
  f.fade[prefix] = { key: lines.key.slice(0, count), alpha: lines.alpha.slice(0, count) };
  const slots = run.slots[prefix === "v" ? "bevel" : "ribs"] ?? 6;
  const levels = run._levels ?? (run._levels = new Array(T3_LEVELS + 1));
  for (let l = 0; l <= T3_LEVELS; l++) levels[l] = "";
  for (let i = 0; i < count; i++) {
    const alpha = lines.alpha[i];
    const level = Math.min(T3_LEVELS, Math.round(alpha * T3_LEVELS));
    if (level <= 0) continue;
    levels[level] += lines.d[i];
  }
  const present = [];
  for (let l = T3_LEVELS; l >= 1; l--) if (levels[l]) present.push([l, levels[l]]);
  while (present.length > slots) {
    let best = 1;
    let gap = Infinity;
    for (let i = 1; i < present.length; i++) {
      const g = present[i - 1][0] - present[i][0];
      if (g < gap) {
        gap = g;
        best = i;
      }
    }
    const [a, da] = present[best - 1];
    const [b, db] = present[best];
    present.splice(best - 1, 2, [(a + b) / 2, da + db]);
  }
  for (let j = 0; j < slots; j++) {
    const role = run.role[`${prefix}${j}`];
    if (role === undefined) continue;
    if (j < present.length) {
      f.d[role] = present[j][1];
      f.o[role] = present[j][0] / T3_LEVELS;
    } else {
      f.d[role] = "";
      f.o[role] = 0;
    }
  }
  return present.length;
}

const t3Lines = { d: [], alpha: [], key: [] };
const t3None = { d: [], alpha: [], key: [] };

function t3EmitPrism(run, cam, f) {
  const n = run.n;
  const m = cam.m;
  const ox = cam.ox;
  const oy = cam.oy;
  const V = cam.V;
  const L = cam.L;
  const lo = run.lo;
  const hi = run.hi;
  const N3 = run.N3;
  const bot = t3Scratch("a", 2 * n);
  const top = t3Scratch("b", 2 * n);
  for (let i = 0; i < n; i++) {
    const a = 3 * i;
    bot[2 * i] = ox + m[0] * lo[a] + m[1] * lo[a + 1] + m[2] * lo[a + 2];
    bot[2 * i + 1] = oy + m[3] * lo[a] + m[4] * lo[a + 1] + m[5] * lo[a + 2];
    top[2 * i] = ox + m[0] * hi[a] + m[1] * hi[a + 1] + m[2] * hi[a + 2];
    top[2 * i + 1] = oy + m[3] * hi[a] + m[4] * hi[a + 1] + m[5] * hi[a + 2];
  }
  const F = run.F;
  const aV = F.a[0] * V[0] + F.a[1] * V[1] + F.a[2] * V[2];
  const near = aV >= 0 ? top : bot;
  const far = aV >= 0 ? bot : top;
  const facing = run._facing ?? (run._facing = new Float64Array(n));
  for (let i = 0; i < n; i++) facing[i] = N3[3 * i] * V[0] + N3[3 * i + 1] * V[1] + N3[3 * i + 2] * V[2];
  const on = (i) => facing[((i % n) + n) % n] > 1e-9;
  let i0 = -1;
  for (let i = 0; i < n; i++)
    if (on(i) && !on(i - 1)) {
      i0 = i;
      break;
    }
  const sil = t3Scratch("c", 4 * n + 8);
  const silAt = run._silAt ?? (run._silAt = new Int32Array(2 * n + 4));
  let count = 0;
  let i1 = i0;
  const nearSet = aV >= 0 ? hi : lo;
  const farSet = aV >= 0 ? lo : hi;
  if (i0 < 0) {
    for (let i = 0; i < n; i++) {
      sil[2 * count] = near[2 * i];
      sil[2 * count + 1] = near[2 * i + 1];
      silAt[count] = i;
      count++;
    }
  } else {
    while (on(i1)) i1++;
    for (let j = i0; j <= i1; j++) {
      const i = j % n;
      sil[2 * count] = far[2 * i];
      sil[2 * count + 1] = far[2 * i + 1];
      silAt[count] = -1 - i;
      count++;
    }
    for (let j = i1; j <= i0 + n; j++) {
      const i = j % n;
      sil[2 * count] = near[2 * i];
      sil[2 * count + 1] = near[2 * i + 1];
      silAt[count] = i;
      count++;
    }
    if (count > 2 && Math.abs(sil[0] - sil[2 * count - 2]) < 1e-9 && Math.abs(sil[1] - sil[2 * count - 1]) < 1e-9) count--;
  }
  const outline = t3Path(sil, count, true);
  f.d[run.role.fill] = outline;
  if (run.cut) {
    const inPlane = (index) => {
      const at = silAt[index];
      const set = at < 0 ? farSet : nearSet;
      const v = at < 0 ? -1 - at : at;
      return run.cut.some((cut) => Math.abs(cut.n[0] * set[3 * v] + cut.n[1] * set[3 * v + 1] + cut.n[2] * set[3 * v + 2] - cut.d) < 1e-3);
    };
    const flags = new Uint8Array(count);
    const onSame = (i, j) => {
      const at = (index) => {
        const a = silAt[index];
        const set = a < 0 ? farSet : nearSet;
        const v = a < 0 ? -1 - a : a;
        return [set[3 * v], set[3 * v + 1], set[3 * v + 2]];
      };
      const p = at(i);
      const q = at(j);
      return run.cut.some((cut) => Math.abs(cut.n[0] * p[0] + cut.n[1] * p[1] + cut.n[2] * p[2] - cut.d) < 1e-3 && Math.abs(cut.n[0] * q[0] + cut.n[1] * q[1] + cut.n[2] * q[2] - cut.d) < 1e-3);
    };
    for (let j = 0; j < count; j++) flags[j] = inPlane(j) && onSame(j, (j + 1) % count) ? 1 : 0;
    f.d[run.role.edge] = t3Broken(sil, count, flags);
  } else f.d[run.role.edge] = outline;
  t3Box(sil, count, f.box);
  t3SetHull(f, sil, count);
  const quad = new Float64Array(8);
  for (let i = 0; i < n; i++) {
    const role = run.role[`f${i}`];
    if (!(facing[i] > 1e-6)) {
      f.d[role] = "";
      continue;
    }
    const j = (i + 1) % n;
    quad[0] = top[2 * i];
    quad[1] = top[2 * i + 1];
    quad[2] = top[2 * j];
    quad[3] = top[2 * j + 1];
    quad[4] = bot[2 * j];
    quad[5] = bot[2 * j + 1];
    quad[6] = bot[2 * i];
    quad[7] = bot[2 * i + 1];
    f.d[role] = t3Path(quad, 4, true);
    let score;
    if (run.up) {
      score = (run.nx[i] * cam.lx + run.ny[i] * cam.ly + 1) / 2;
      f.q[role] = t3Level(score, T3_EB);
    } else {
      score = N3[3 * i] * L[0] + N3[3 * i + 1] * L[1] + N3[3 * i + 2] * L[2];
      f.q[role] = t3Level(score, T3_LB);
    }
    const e1 = Math.hypot(quad[2] - quad[0], quad[3] - quad[1]);
    const e2 = Math.hypot(quad[6] - quad[0], quad[7] - quad[1]);
    const area = Math.abs((quad[2] - quad[0]) * (quad[7] - quad[1]) - (quad[3] - quad[1]) * (quad[6] - quad[0]));
    const wide = area / (Math.max(e1, e2) || 1);
    f.w[role] = wide < 0.4 ? Math.round(wide * 100) / 100 : -1;
  }
  if (run.up) {
    const z = run.s1;
    f.t[run.role.top] = `matrix(${t3Fix(m[0], 1e5)} ${t3Fix(m[3], 1e5)} ${t3Fix(m[1], 1e5)} ${t3Fix(m[4], 1e5)} ${t3Fix(ox + m[2] * z, 100)} ${t3Fix(oy + m[5] * z, 100)})`;
  } else {
    for (let side = 0; side < 2; side++) {
      const role = run.role[`f${n + side}`];
      const sign = side ? 1 : -1;
      const face = sign * aV;
      if (!(face > 1e-6)) {
        f.d[role] = "";
        continue;
      }
      const ring = side ? top : bot;
      f.d[role] = t3Path(ring, n, true);
      f.q[role] = t3Level(sign * (F.a[0] * L[0] + F.a[1] * L[1] + F.a[2] * L[2]), T3_LB);
      let minor = Infinity;
      let x0 = Infinity;
      let x1 = -Infinity;
      for (let i = 0; i < n; i++) {
        const j = (i + 1) % n;
        const ex = ring[2 * j] - ring[2 * i];
        const ey = ring[2 * j + 1] - ring[2 * i + 1];
        const l = Math.hypot(ex, ey);
        if (l < 1e-9) continue;
        let lo2 = Infinity;
        let hi2 = -Infinity;
        for (let q = 0; q < n; q++) {
          const v = ((ring[2 * q] - ring[2 * i]) * ey - (ring[2 * q + 1] - ring[2 * i + 1]) * ex) / l;
          if (v < lo2) lo2 = v;
          if (v > hi2) hi2 = v;
        }
        minor = Math.min(minor, hi2 - lo2);
        x0 = Math.min(x0, lo2);
        x1 = Math.max(x1, hi2);
      }
      f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    }
  }
  if (run.role.crease !== undefined) {
    let crease = "";
    if (run.up) {
      if (i0 >= 0) {
        const buf = t3Scratch("a", 0);
        let c = 0;
        const rim = t3Scratch("c", 2 * n + 4);
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          rim[2 * c] = top[2 * i];
          rim[2 * c + 1] = top[2 * i + 1];
          c++;
        }
        crease = t3Path(rim, c, false);
        void buf;
      }
    } else {
      const capOn = [-aV > 1e-6, aV > 1e-6];
      const seg = new Float64Array(4);
      for (let i = 0; i < n; i++) {
        const p = (i - 1 + n) % n;
        if (run.sharp[i] && facing[i] > 1e-6 && facing[p] > 1e-6) {
          seg[0] = bot[2 * i];
          seg[1] = bot[2 * i + 1];
          seg[2] = top[2 * i];
          seg[3] = top[2 * i + 1];
          crease += t3Path(seg, 2, false);
        }
      }
      for (let side = 0; side < 2; side++) {
        if (!capOn[side] || i0 < 0) continue;
        const ring = side ? top : bot;
        const rim = t3Scratch("c", 2 * n + 4);
        let c = 0;
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          rim[2 * c] = ring[2 * i];
          rim[2 * c + 1] = ring[2 * i + 1];
          c++;
        }
        crease += t3Path(rim, c, false);
      }
    }
    f.d[run.role.crease] = crease;
  }
  if (run.inset && run.role.v0 !== undefined && t3State.detail < 0.01) f.levels = t3Faded(f, run, "v", t3None, 0);
  else if (run.inset && run.role.v0 !== undefined) {
    const lines = t3Lines;
    lines.d.length = 0;
    lines.alpha.length = 0;
  lines.key.length = 0;
    lines.key.length = 0;
    const capSide = aV >= 0 ? 1 : 0;
    const s = capSide ? run.s1 : run.s0;
    const capAlpha = run.up ? 1 : t3Step(0.02, 0.2, Math.abs(aV));
    const ins = t3Scratch("c", 2 * n);
    for (let i = 0; i < n; i++) {
      const x = run.inset[2 * i];
      const y = run.inset[2 * i + 1];
      const px = F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y;
      const py = F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y;
      const pz = F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y;
      ins[2 * i] = ox + m[0] * px + m[1] * py + m[2] * pz;
      ins[2 * i + 1] = oy + m[3] * px + m[4] * py + m[5] * pz;
    }
    const rim = capSide ? top : bot;
    const seg = new Float64Array(4);
    for (let i = 0; i < n; i++) {
      const p = (i - 1 + n) % n;
      const ai = capAlpha * t3Step(0, 0.14, facing[i]);
      const ap = capAlpha * t3Step(0, 0.14, facing[p]);
      if (ai > 0.01) {
        const j = (i + 1) % n;
        seg[0] = ins[2 * i];
        seg[1] = ins[2 * i + 1];
        seg[2] = ins[2 * j];
        seg[3] = ins[2 * j + 1];
        lines.d.push(t3Path(seg, 2, false));
        lines.alpha.push(ai);
        lines.key.push(2 * i);
      }
      const corner = ai * (1 - ap) + ap * (1 - ai);
      if (corner > 0.01) {
        seg[0] = ins[2 * i];
        seg[1] = ins[2 * i + 1];
        seg[2] = rim[2 * i];
        seg[3] = rim[2 * i + 1];
        lines.d.push(t3Path(seg, 2, false));
        lines.alpha.push(corner);
        lines.key.push(2 * i + 1);
      }
    }
    f.levels = t3Faded(f, run, "v", lines, lines.d.length);
  }
  if (run.seams && run.role.inner !== undefined) {
    let inner = "";
    if (i0 >= 0) {
      const rim = t3Scratch("c", 2 * n + 4);
      for (const s of run.seams) {
        let c = 0;
        for (let j = i0; j <= i1; j++) {
          const i = j % n;
          const x = run.ring[2 * i];
          const y = run.ring[2 * i + 1];
          const px = F.o[0] + F.a[0] * s + F.u[0] * x + F.v[0] * y;
          const py = F.o[1] + F.a[1] * s + F.u[1] * x + F.v[1] * y;
          const pz = F.o[2] + F.a[2] * s + F.u[2] * x + F.v[2] * y;
          rim[2 * c] = ox + m[0] * px + m[1] * py + m[2] * pz;
          rim[2 * c + 1] = oy + m[3] * px + m[4] * py + m[5] * pz;
          c++;
        }
        inner += t3Path(rim, c, false);
      }
    }
    f.d[run.role.inner] = inner;
  }
}

function t3Broken(buf, count, skip) {
  let start = -1;
  for (let j = 0; j < count; j++)
    if (skip[(j - 1 + count) % count] && !skip[j]) {
      start = j;
      break;
    }
  if (start < 0) return skip[0] ? "" : t3Path(buf, count, true);
  let out = "";
  let run = [];
  for (let step = 0; step < count; step++) {
    const j = (start + step) % count;
    if (skip[j]) {
      if (run.length) {
        run.push((j + 0) % count);
        out += t3PathOf(run.map((i) => [buf[2 * i], buf[2 * i + 1]]), false);
        run = [];
      }
      continue;
    }
    run.push(j);
  }
  if (run.length) {
    run.push((start + count) % count);
    out += t3PathOf(run.map((i) => [buf[2 * i], buf[2 * i + 1]]), false);
  }
  return out;
}

function t3EmitPlanes(run, cam, f, sx = 0, sy = 0) {
  const m = cam.m;
  const V = cam.V;
  for (let j = 0; j < run.planes.length; j++) {
    const plane = run.planes[j];
    const role = run.role[`plane${j}`];
    const o = plane.o;
    const X = cam.ox - sx + m[0] * o[0] + m[1] * o[1] + m[2] * o[2];
    const Y = cam.oy - sy + m[3] * o[0] + m[4] * o[1] + m[5] * o[2];
    const facing = plane.n[0] * V[0] + plane.n[1] * V[1] + plane.n[2] * V[2];
    const alpha = Math.round(t3Step(plane.fade[0], plane.fade[1], facing) * 100) / 100;
    f.o[role] = alpha;
    if (plane.billboard) {
      f.t[role] = `translate(${t3Num(X)} ${t3Num(Y)})`;
      continue;
    }
    const u = plane.u;
    const v = plane.v;
    const a = m[0] * u[0] + m[1] * u[1] + m[2] * u[2];
    const b = m[3] * u[0] + m[4] * u[1] + m[5] * u[2];
    const c = m[0] * v[0] + m[1] * v[1] + m[2] * v[2];
    const d = m[3] * v[0] + m[4] * v[1] + m[5] * v[2];
    f.t[role] = `matrix(${t3Fix(a, 1e5)} ${t3Fix(b, 1e5)} ${t3Fix(c, 1e5)} ${t3Fix(d, 1e5)} ${t3Fix(X, 100)} ${t3Fix(Y, 100)})`;
  }
}

function t3EmitDots(run, cam, f, sx = 0, sy = 0) {
  const m = cam.m;
  const V = cam.V;
  for (let j = 0; j < run.dots.length; j++) {
    const dot = run.dots[j];
    const role = run.role[`c${j}`];
    const p = dot.p;
    f.cx[role] = cam.ox - sx + m[0] * p[0] + m[1] * p[1] + m[2] * p[2];
    f.cy[role] = cam.oy - sy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2];
    const alpha = (dot.n ? t3Step(dot.fade[0], dot.fade[1], dot.n[0] * V[0] + dot.n[1] * V[1] + dot.n[2] * V[2]) : 1) * t3State.detail;
    f.o[role] = alpha < 0.005 ? 0 : Math.round(alpha * 100) / 100;
  }
}

function t3EmitFixed(run, cam, f) {
  const a = run.anchor;
  const m = cam.m;
  const X = cam.ox + m[0] * a[0] + m[1] * a[1] + m[2] * a[2];
  const Y = cam.oy + m[3] * a[0] + m[4] * a[1] + m[5] * a[2];
  const dx = X - run.home[0];
  const dy = Y - run.home[1];
  const rx = Math.round(dx * 100) / 100;
  const ry = Math.round(dy * 100) / 100;
  f.t[run.role.move] = rx || ry ? `translate(${t3Num(rx)} ${t3Num(ry)})` : "";
  f.shiftX = rx;
  f.shiftY = ry;
  const b = run.box0;
  f.box[0] = b[0] + rx;
  f.box[1] = b[1] + ry;
  f.box[2] = b[2] + rx;
  f.box[3] = b[3] + ry;
  const count = run.hull0.length / 2;
  const hull = t3Grow(f, "hull", 2 * count, Float64Array);
  for (let i = 0; i < count; i++) {
    hull[2 * i] = run.hull0[2 * i] + rx;
    hull[2 * i + 1] = run.hull0[2 * i + 1] + ry;
  }
  f.hullN = count;
  if (run.ribs && run.F) t3EmitRibs(run, cam, f, run.F, run.ribs, rx, ry);
  t3EmitPlanes(run, cam, f, rx, ry);
  t3EmitDots(run, cam, f, rx, ry);
}

function t3EmitRibs(run, cam, f, F, ribs, sx, sy) {
  if (t3State.detail < 0.01) {
    f.levels = t3Faded(f, run, "r", t3None, 0);
    return;
  }
  const m = cam.m;
  const V = cam.V;
  const lines = t3Lines;
  lines.d.length = 0;
  lines.alpha.length = 0;
  lines.key.length = 0;
  const { s0, s1, count, phase = 0, twist = 0, fade = [0.1, 0.55] } = ribs;
  const r0 = ribs.r0 ?? ribs.r;
  const r1 = ribs.r1 ?? r0;
  const slope = (r1 - r0) / (s1 - s0 || 1);
  const norm = Math.hypot(1, slope);
  const aV = F.a[0] * V[0] + F.a[1] * V[1] + F.a[2] * V[2];
  const uV = F.u[0] * V[0] + F.u[1] * V[1] + F.u[2] * V[2];
  const vV = F.v[0] * V[0] + F.v[1] * V[1] + F.v[2] * V[2];
  const seg = new Float64Array(4);
  const at = (s, r, t, out, index) => {
    const c = Math.cos(t);
    const n = Math.sin(t);
    const px = F.o[0] + F.a[0] * s + r * (c * F.u[0] + n * F.v[0]);
    const py = F.o[1] + F.a[1] * s + r * (c * F.u[1] + n * F.v[1]);
    const pz = F.o[2] + F.a[2] * s + r * (c * F.u[2] + n * F.v[2]);
    out[index] = cam.ox - sx + m[0] * px + m[1] * py + m[2] * pz;
    out[index + 1] = cam.oy - sy + m[3] * px + m[4] * py + m[5] * pz;
  };
  for (let i = 0; i < count; i++) {
    const t = phase + (i / count) * T3_TAU;
    const tm = t + twist / 2;
    const facing = (Math.cos(tm) * uV + Math.sin(tm) * vV - slope * aV) / norm;
    const alpha = t3Step(fade[0], fade[1], facing);
    if (alpha * T3_LEVELS < 0.5) continue;
    at(s0, r0, t, seg, 0);
    at(s1, r1, t + twist, seg, 2);
    lines.d.push(t3Path(seg, 2, false));
    lines.alpha.push(alpha);
    lines.key.push(i);
  }
  f.levels = t3Faded(f, run, "r", lines, lines.d.length);
}


function t3Geo(F, cam) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const lin = (w) => [m[0] * w[0] + m[1] * w[1] + m[2] * w[2], m[3] * w[0] + m[4] * w[1] + m[5] * w[2]];
  const O = [cam.ox + m[0] * F.o[0] + m[1] * F.o[1] + m[2] * F.o[2], cam.oy + m[3] * F.o[0] + m[4] * F.o[1] + m[5] * F.o[2]];
  const aV = dot(F.a, V);
  const uV = dot(F.u, V);
  const vV = dot(F.v, V);
  const aL = dot(F.a, L);
  const uL = dot(F.u, L);
  const vL = dot(F.v, L);
  return { O, SA: lin(F.a), SU: lin(F.u), SV: lin(F.v), aV, aL, view: { a: aV, flat: Math.hypot(uV, vV), phi: Math.atan2(vV, uV) }, light: { a: aL, flat: Math.hypot(uL, vL), phi: Math.atan2(vL, uL) } };
}

function t3At(geo, s, r, t, buf, index) {
  const c = Math.cos(t);
  const n = Math.sin(t);
  buf[index] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + n * geo.SV[0]);
  buf[index + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + n * geo.SV[1]);
}

function t3Arcs(nr, na, view, light) {
  const cuts = [];
  if (Math.abs(nr) > 1e-9) {
    const visibleCut = -(na * view.a) / (nr * view.flat);
    if (visibleCut > -1 && visibleCut < 1) {
      const d = Math.acos(visibleCut);
      cuts.push(view.phi - d, view.phi + d);
    }
    for (let b = 0; b < T3_LB.length; b++) {
      const c = (T3_LB[b] - na * light.a) / (nr * light.flat);
      if (c > -1 && c < 1) {
        const d = Math.acos(c);
        cuts.push(light.phi - d, light.phi + d);
      }
    }
  }
  for (let i = 0; i < cuts.length; i++) {
    const w = ((cuts[i] % T3_TAU) + T3_TAU) % T3_TAU;
    cuts[i] = Math.round(w * 1e9) / 1e9;
  }
  cuts.sort((a, b) => a - b);
  const sorted = [];
  for (let i = 0; i < cuts.length; i++) if (!sorted.length || cuts[i] !== sorted[sorted.length - 1]) sorted.push(cuts[i]);
  const label = (angle) => {
    const facing = nr * view.flat * Math.cos(angle - view.phi) + na * view.a;
    if (facing <= 1e-9) return -1;
    const score = nr * light.flat * Math.cos(angle - light.phi) + na * light.a;
    let tone = 0;
    while (tone < T3_LB.length && score >= T3_LB[tone]) tone++;
    return tone;
  };
  if (!sorted.length) {
    const tone = label(0);
    return tone < 0 ? [] : [{ tone, from: 0, to: T3_TAU, full: true }];
  }
  const merged = [];
  for (let index = 0; index < sorted.length; index++) {
    const from = sorted[index];
    const to = index + 1 < sorted.length ? sorted[index + 1] : sorted[0] + T3_TAU;
    const tone = label((from + to) / 2);
    const last = merged[merged.length - 1];
    if (last && last.tone === tone && Math.abs(last.to - from) < 1e-9) last.to = to;
    else merged.push({ tone, from, to });
  }
  if (merged.length > 1 && merged[0].tone === merged[merged.length - 1].tone && Math.abs(merged[merged.length - 1].to - T3_TAU - merged[0].from) < 1e-9) {
    const last = merged.pop();
    merged[0].from = last.from - T3_TAU;
  }
  const out = [];
  for (const arc of merged) if (arc.tone >= 0) out.push(arc);
  return out;
}

function t3Strip(geo, s0, r0, s1, r1, from, to, step) {
  const count = Math.max(1, Math.ceil((to - from) / step));
  const buf = t3Scratch("c", 4 * count + 8);
  for (let j = 0; j <= count; j++) {
    const t = from + ((to - from) * j) / count;
    t3At(geo, s0, r0, t, buf, 2 * j);
    t3At(geo, s1, r1, t, buf, 2 * (2 * count + 1 - j));
  }
  return t3Path(t3Oriented(buf, 2 * count + 2), 2 * count + 2, true);
}

function t3Oriented(buf, count) {
  let area = 0;
  for (let i = 0; i < count; i++) {
    const j = (i + 1) % count;
    area += buf[2 * i] * buf[2 * j + 1] - buf[2 * j] * buf[2 * i + 1];
  }
  if (area >= 0) return buf;
  for (let i = 0, j = count - 1; i < j; i++, j--) {
    const x = buf[2 * i];
    const y = buf[2 * i + 1];
    buf[2 * i] = buf[2 * j];
    buf[2 * i + 1] = buf[2 * j + 1];
    buf[2 * j] = x;
    buf[2 * j + 1] = y;
  }
  return buf;
}

function t3Hull(points) {
  const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  if (sorted.length < 3) return sorted;
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lower = [];
  for (const p of sorted) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 1e-12) lower.pop();
    lower.push(p);
  }
  const upper = [];
  for (let i = sorted.length - 1; i >= 0; i--) {
    const p = sorted[i];
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 1e-12) upper.pop();
    upper.push(p);
  }
  return lower.slice(0, -1).concat(upper.slice(0, -1));
}

function t3PathOf(points, closed) {
  if (points.length < 2) return "";
  let s = "M" + t3Num(points[0][0]) + " " + t3Num(points[0][1]);
  for (let i = 1; i < points.length; i++) s += "L" + t3Num(points[i][0]) + " " + t3Num(points[i][1]);
  return closed ? s + "Z" : s;
}

function t3Basis(w) {
  const helper = Math.abs(w[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
  const u = t3Unit([helper[1] * w[2] - helper[2] * w[1], helper[2] * w[0] - helper[0] * w[2], helper[0] * w[1] - helper[1] * w[0]]);
  return [u, [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]]];
}

function t3Meet(A, a, B, b) {
  const ab = A[0] * B[0] + A[1] * B[1] + A[2] * B[2];
  const det = 1 - ab * ab;
  if (det < 1e-12) return [];
  const alpha = (a - b * ab) / det;
  const beta = (b - a * ab) / det;
  const base = [A[0] * alpha + B[0] * beta, A[1] * alpha + B[1] * beta, A[2] * alpha + B[2] * beta];
  const rest = 1 - (base[0] * base[0] + base[1] * base[1] + base[2] * base[2]);
  if (rest < 0) return [];
  const cr = [A[1] * B[2] - A[2] * B[1], A[2] * B[0] - A[0] * B[2], A[0] * B[1] - A[1] * B[0]];
  const k = Math.sqrt(rest / det);
  return [
    [base[0] + cr[0] * k, base[1] + cr[1] * k, base[2] + cr[2] * k],
    [base[0] - cr[0] * k, base[1] - cr[1] * k, base[2] - cr[2] * k],
  ];
}

const t3Tables = new Map();
function t3Table(count) {
  let table = t3Tables.get(count);
  if (!table) {
    table = new Float64Array(2 * count);
    for (let j = 0; j < count; j++) {
      table[2 * j] = Math.cos((j / count) * T3_TAU);
      table[2 * j + 1] = Math.sin((j / count) * T3_TAU);
    }
    t3Tables.set(count, table);
  }
  return table;
}

function t3Dome(cam, C, e, r, count, sheets) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const Sx = cam.ox + m[0] * C[0] + m[1] * C[1] + m[2] * C[2];
  const Sy = cam.oy + m[3] * C[0] + m[4] * C[1] + m[5] * C[2];
  const flat = (n) => [Sx + r * (m[0] * n[0] + m[1] * n[1] + m[2] * n[2]), Sy + r * (m[3] * n[0] + m[4] * n[1] + m[5] * n[2])];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const table = t3Table(count);
  const circle = (W, value, keep) => {
    const [U, Q] = t3Basis(W);
    const rad = Math.sqrt(Math.max(0, 1 - value * value));
    const out = [];
    for (let j = 0; j < count; j++) {
      const cs = table[2 * j] * rad;
      const sn = table[2 * j + 1] * rad;
      const n = [W[0] * value + U[0] * cs + Q[0] * sn, W[1] * value + U[1] * cs + Q[1] * sn, W[2] * value + U[2] * cs + Q[2] * sn];
      if (keep(n)) out.push(n);
    }
    return out;
  };
  const rim = circle(V, 0, (n) => dot(n, e) >= -1e-9).map((n) => [flat(n), dot(n, L)]);
  const base = circle(e, 0, (n) => dot(n, V) >= -1e-9).map((n) => [flat(n), dot(n, L)]);
  const corners = t3Meet(V, 0, e, 0).map((n) => [flat(n), dot(n, L)]);
  for (let tone = 0; tone < 5; tone++) {
    const tau = tone === 0 ? -Infinity : T3_LB[tone - 1];
    const candidates = [];
    for (const list of [rim, base, corners]) for (const [p, lit] of list) if (lit >= tau - 1e-9) candidates.push(p);
    if (tone > 0) {
      for (const n of circle(L, tau, (n) => dot(n, V) >= -1e-9 && dot(n, e) >= -1e-9)) candidates.push(flat(n));
      for (const n of t3Meet(L, tau, V, 0)) if (dot(n, e) >= -1e-9) candidates.push(flat(n));
      for (const n of t3Meet(L, tau, e, 0)) if (dot(n, V) >= -1e-9) candidates.push(flat(n));
    }
    if (candidates.length < 3) continue;
    const region = t3Hull(candidates);
    if (region.length >= 3) sheets[tone] += t3PathOf(region, true);
  }
}

function t3BoxOf(pts, box) {
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const [x, y] of pts) {
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  box[0] = x0;
  box[1] = y0;
  box[2] = x1;
  box[3] = y1;
}

function t3Thin(points, most) {
  if (points.length <= most) return points;
  const n = points.length;
  const picks = [];
  for (let i = 0; i < most; i++) picks.push(Math.floor((i * n) / most));
  const thin = picks.map((i) => points[i]);
  let err = 0;
  for (let j = 0; j < most; j++) {
    const a = thin[j];
    const b = thin[(j + 1) % most];
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const l = Math.hypot(dx, dy) || 1;
    const end = j + 1 < most ? picks[j + 1] : picks[0] + n;
    for (let i = picks[j] + 1; i < end; i++) {
      const p = points[i % n];
      const out = ((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / l;
      if (Math.abs(out) > err) err = Math.abs(out);
    }
  }
  return t3Grown(thin, err);
}

function t3Grown(poly, by) {
  if (!(by > 0)) return poly;
  let area = 0;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = area >= 0 ? 1 : -1;
  const n = poly.length;
  const normals = poly.map((a, i) => {
    const b = poly[(i + 1) % n];
    const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    return [(sign * (b[1] - a[1])) / l, (-sign * (b[0] - a[0])) / l];
  });
  return poly.map((p, i) => {
    const na = normals[(i - 1 + n) % n];
    const nb = normals[i];
    const mx = na[0] + nb[0];
    const my = na[1] + nb[1];
    const dd = (mx * mx + my * my) / 2 || 1;
    return [p[0] + (mx * by) / dd, p[1] + (my * by) / dd];
  });
}

function t3HullOut(f, points, most = 32) {
  const thin = t3Thin(points, most);
  const hull = t3Grow(f, "hull", 2 * thin.length, Float64Array);
  for (let i = 0; i < thin.length; i++) {
    hull[2 * i] = thin[i][0];
    hull[2 * i + 1] = thin[i][1];
  }
  f.hullN = thin.length;
}

function t3SupportHull(geo, cam, components, dirs) {
  const table = t3Table(dirs);
  const pts = [];
  const { O, SA, SU, SV } = geo;
  for (let j = 0; j < dirs; j++) {
    const dx = table[2 * j];
    const dy = table[2 * j + 1];
    const pa = SA[0] * dx + SA[1] * dy;
    const pu = SU[0] * dx + SU[1] * dy;
    const pv = SV[0] * dx + SV[1] * dy;
    const w = Math.hypot(pu, pv);
    let best = -Infinity;
    let px = 0;
    let py = 0;
    for (let c = 0; c < components.length; c += 3) {
      const kind = components[c];
      const s = components[c + 1];
      const r = components[c + 2];
      if (kind === 0) {
        const value = s * pa + r * w;
        if (value > best) {
          best = value;
          const cs = w > 1e-12 ? pu / w : 1;
          const sn = w > 1e-12 ? pv / w : 0;
          px = O[0] + s * SA[0] + r * (cs * SU[0] + sn * SV[0]);
          py = O[1] + s * SA[1] + r * (cs * SU[1] + sn * SV[1]);
        }
      } else {
        const rk = r * cam.k;
        const value = s * pa + rk;
        if (value > best) {
          best = value;
          px = O[0] + s * SA[0] + rk * dx;
          py = O[1] + s * SA[1] + rk * dy;
        }
      }
    }
    const last = pts[pts.length - 1];
    if (!last || Math.abs(last[0] - px) + Math.abs(last[1] - py) > 0.02) pts.push([px, py]);
  }
  if (pts.length > 2) {
    const a = pts[0];
    const b = pts[pts.length - 1];
    if (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) <= 0.02) pts.pop();
  }
  return pts;
}

function t3ArcPoints(geo, s, r, from, to, k, out, cut = false) {
  const count = Math.max(2, Math.ceil((Math.abs(to - from) * 48) / T3_TAU), Math.ceil((Math.abs(to - from) * Math.max(r, 1e-6) * k) / 1.5));
  for (let j = 0; j <= count; j++) {
    const t = from + ((to - from) * j) / count;
    const c = Math.cos(t);
    const n = Math.sin(t);
    const p = [geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + n * geo.SV[0]), geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + n * geo.SV[1])];
    if (cut) p.cut = true;
    out.push(p);
  }
}

function t3RoundOutline(run, geo, cam) {
  const k = cam.k;
  const { s0, s1, r0, r1, ends } = run;
  const domes = (ends[0] === "dome" ? 1 : 0) + (ends[1] === "dome" ? 1 : 0);
  if (domes === 2) {
    const c0 = [geo.O[0] + s0 * geo.SA[0], geo.O[1] + s0 * geo.SA[1]];
    const c1 = [geo.O[0] + s1 * geo.SA[0], geo.O[1] + s1 * geo.SA[1]];
    const rk = r0 * k;
    const dx = c1[0] - c0[0];
    const dy = c1[1] - c0[1];
    const l = Math.hypot(dx, dy);
    const count = Math.max(24, Math.ceil((Math.PI * rk) / 1.5));
    const out = [];
    if (l < 1e-6) {
      for (let j = 0; j < 2 * count; j++) {
        const t = (j / (2 * count)) * T3_TAU;
        out.push([c1[0] + rk * Math.cos(t), c1[1] + rk * Math.sin(t)]);
      }
      return out;
    }
    const beta = Math.atan2(dy, dx);
    for (let j = 0; j <= count; j++) {
      const t = beta - Math.PI / 2 + (Math.PI * j) / count;
      out.push([c1[0] + rk * Math.cos(t), c1[1] + rk * Math.sin(t)]);
    }
    for (let j = 0; j <= count; j++) {
      const t = beta + Math.PI / 2 + (Math.PI * j) / count;
      out.push([c0[0] + rk * Math.cos(t), c0[1] + rk * Math.sin(t)]);
    }
    return out;
  }
  if (domes === 1) {
    const pts = [];
    for (const [end, s, r] of [[0, s0, r0], [1, s1, r1]]) {
      t3ArcPoints(geo, s, r, 0, T3_TAU, k, pts);
      if (ends[end] !== "dome") continue;
      const sign = end ? 1 : -1;
      const c = [geo.O[0] + s * geo.SA[0], geo.O[1] + s * geo.SA[1]];
      const rk = r * k;
      const beta = Math.atan2(sign * geo.SA[1], sign * geo.SA[0]);
      const count = Math.max(24, Math.ceil((Math.PI * rk) / 1.5));
      for (let j = 0; j <= count; j++) {
        const t = beta - Math.PI / 2 + (Math.PI * j) / count;
        pts.push([c[0] + rk * Math.cos(t), c[1] + rk * Math.sin(t)]);
      }
    }
    return t3Hull(pts);
  }
  const view = geo.view;
  const out = [];
  let c;
  if (view.flat < 1e-9) c = run.na * view.a > 0 ? -2 : 2;
  else c = -(run.na * view.a) / (run.nr * view.flat);
  if (c <= -1 || c >= 1) {
    const side = c <= -1;
    const capFirst = -geo.aV > 0;
    const keepFirst = side ? !capFirst : capFirst;
    const s = keepFirst ? s0 : s1;
    const r = keepFirst ? r0 : r1;
    t3ArcPoints(geo, s, r, 0, T3_TAU, k, out, run.cutS !== null && Math.abs(run.cutS - s) < 1e-6);
    out.pop();
    return out;
  }
  const d = Math.acos(c);
  const lo = view.phi - d;
  const hi = view.phi + d;
  const cut0 = run.cutS !== null && Math.abs(run.cutS - s0) < 1e-6;
  const cut1 = run.cutS !== null && Math.abs(run.cutS - s1) < 1e-6;
  if (-geo.aV > 0) t3ArcPoints(geo, s0, r0, hi, lo + T3_TAU, k, out, cut0);
  else t3ArcPoints(geo, s0, r0, lo, hi, k, out, cut0);
  const tail = [];
  if (geo.aV > 0) t3ArcPoints(geo, s1, r1, hi, lo + T3_TAU, k, tail, cut1);
  else t3ArcPoints(geo, s1, r1, lo, hi, k, tail, cut1);
  const first = out[out.length - 1];
  const a = tail[0];
  const b = tail[tail.length - 1];
  if (Math.hypot(a[0] - first[0], a[1] - first[1]) > Math.hypot(b[0] - first[0], b[1] - first[1])) tail.reverse();
  for (const p of tail) out.push(p);
  return out;
}

function t3LatheMid(a, b) {
  const h = b.s - a.s;
  const ga = -a.na / a.nr;
  const gb = -b.na / b.nr;
  const nr = a.nr + b.nr;
  const na = a.na + b.na;
  const l = Math.hypot(nr, na) || 1;
  return { s: (a.s + b.s) / 2, r: (a.r + b.r) / 2 + (h * (ga - gb)) / 8, nr: nr / l, na: na / l };
}

function t3LatheOutline(run, geo, cam) {
  const k = cam.k;
  const view = geo.view;
  const lv = run.levels;
  const pts = [];
  const cut = (L) => (view.flat < 1e-9 ? Infinity : -(L.na * view.a) / (L.nr * view.flat));
  const inside = (L) => L.r <= 1e-9 || Math.abs(cut(L)) < 1;
  const sides = (L) => {
    if (L.r <= 1e-9) {
      const p = [geo.O[0] + L.s * geo.SA[0], geo.O[1] + L.s * geo.SA[1]];
      return [p, p];
    }
    const c = Math.max(-1, Math.min(1, cut(L)));
    const d = Math.acos(c);
    return [view.phi - d, view.phi + d].map((t) => {
      const cs = Math.cos(t);
      const sn = Math.sin(t);
      return [geo.O[0] + L.s * geo.SA[0] + L.r * (cs * geo.SU[0] + sn * geo.SV[0]), geo.O[1] + L.s * geo.SA[1] + L.r * (cs * geo.SU[1] + sn * geo.SV[1])];
    });
  };
  const span = (a, b) => {
    const pa = sides(a);
    const pb = sides(b);
    return Math.max(Math.hypot(pa[0][0] - pb[0][0], pa[0][1] - pb[0][1]), Math.hypot(pa[1][0] - pb[1][0], pa[1][1] - pb[1][1]));
  };
  const refine = (a, b, depth) => {
    if (depth >= 10 || span(a, b) < 1.2) return;
    const mid = t3LatheMid(a, b);
    refine(a, mid, depth + 1);
    pts.push(...sides(mid));
    refine(mid, b, depth + 1);
  };
  let previous = null;
  for (let i = 0; i < lv.length; i += 4) {
    const L = { s: lv[i], r: lv[i + 1], nr: lv[i + 2], na: lv[i + 3] };
    if (inside(L)) pts.push(...sides(L));
    if (previous) {
      const pin = inside(previous);
      const lin = inside(L);
      if (pin && lin) refine(previous, L, 0);
      else if (pin !== lin) {
        let a = pin ? previous : L;
        let b = pin ? L : previous;
        const start = a;
        for (let it = 0; it < 14; it++) {
          const mid = t3LatheMid(a.s < b.s ? a : b, a.s < b.s ? b : a);
          if (inside(mid)) a = mid;
          else b = mid;
        }
        pts.push(...sides(a));
        refine(start.s < a.s ? start : a, start.s < a.s ? a : start, 0);
      }
    }
    previous = L;
  }
  for (const i of [0, lv.length - 4]) if (lv[i + 1] > 1e-9) t3ArcPoints(geo, lv[i], lv[i + 1], 0, T3_TAU, k, pts);
  return t3Hull(pts);
}

function t3SeamArc(geo, s, r, slope, k, trim = 0.3) {
  const view = geo.view;
  const norm = Math.hypot(1, slope);
  let lo;
  let hi;
  if (view.flat < 1e-9) {
    if (!((-slope * view.a) / norm > 0)) return "";
    lo = view.phi;
    hi = view.phi + T3_TAU;
  } else {
    const cc = (slope * view.a) / view.flat;
    if (cc >= 1) return "";
    if (cc <= -1) {
      lo = view.phi;
      hi = view.phi + T3_TAU;
    } else {
      const d = Math.acos(cc);
      lo = view.phi - d;
      hi = view.phi + d;
    }
  }
  const full = hi - lo >= T3_TAU - 1e-9;
  const buf = t3Scratch("c", 2 * 520);
  if (!full) {
    const span = hi - lo;
    const cutAt = (from, sign) => {
      t3At(geo, s, r, from, buf, 0);
      const px = buf[0];
      const py = buf[1];
      let a = 0;
      let b = span * 0.25;
      t3At(geo, s, r, from + sign * b, buf, 0);
      if (Math.hypot(buf[0] - px, buf[1] - py) <= trim) return b;
      for (let it = 0; it < 18; it++) {
        const mid = (a + b) / 2;
        t3At(geo, s, r, from + sign * mid, buf, 0);
        if (Math.hypot(buf[0] - px, buf[1] - py) < trim) a = mid;
        else b = mid;
      }
      return (a + b) / 2;
    };
    const d0 = cutAt(lo, 1);
    const d1 = cutAt(hi, -1);
    lo += d0;
    hi -= d1;
  }
  const count = Math.min(512, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
  for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, buf, 2 * j);
  return t3Path(buf, full ? count : count + 1, full);
}

function t3RibLines(geo, F, cam, ribs, sx, sy) {
  const lines = t3Lines;
  lines.d.length = 0;
  lines.alpha.length = 0;
  lines.key.length = 0;
  const { s0, s1, count, phase = 0, twist = 0, fade = [0.1, 0.55] } = ribs;
  const r0 = ribs.r0 ?? ribs.r;
  const r1 = ribs.r1 ?? r0;
  const slope = (r1 - r0) / (s1 - s0 || 1);
  const norm = Math.hypot(1, slope);
  const view = geo.view;
  const seg = new Float64Array(4);
  for (let i = 0; i < count; i++) {
    const t = phase + (i / count) * T3_TAU;
    const tm = t + twist / 2;
    const facing = (view.flat * Math.cos(tm - view.phi) - slope * view.a) / norm;
    const alpha = t3Step(fade[0], fade[1], facing);
    if (alpha * T3_LEVELS < 0.5) continue;
    t3At(geo, s0, r0, t, seg, 0);
    t3At(geo, s1, r1, t + twist, seg, 2);
    seg[0] -= sx;
    seg[1] -= sy;
    seg[2] -= sx;
    seg[3] -= sy;
    lines.d.push(t3Path(seg, 2, false));
    lines.alpha.push(alpha);
    lines.key.push(i);
  }
  return lines;
}

function t3PrepareRound(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.F = spec.F;
  run.s0 = spec.s0;
  run.s1 = spec.s1;
  run.r0 = spec.r0;
  run.r1 = spec.r1;
  run.ends = spec.ends;
  const g = (spec.r1 - spec.r0) / (spec.s1 - spec.s0 || 1);
  const norm = Math.hypot(1, g);
  run.nr = 1 / norm;
  run.na = -g / norm;
  run.g = g;
  const rmax = Math.max(spec.r0, spec.r1);
  const around = T3_TAU * rmax * k * unit;
  run.fine = Math.max(around < 30 ? 16 : 24, Math.min(96, Math.ceil(around / 2.2)));
  run.dirs = Math.max(around < 30 ? 16 : 32, Math.min(256, Math.ceil(around / 1.5)));
  run.capCount = Math.max(around < 30 ? 16 : 24, Math.min(160, Math.ceil(around / 1.5)));
  run.domeCount = Math.max(around < 30 ? 12 : 16, Math.min(48, Math.round(run.fine * 0.75)));
  const comps = [];
  for (const [end, s, r] of [[0, spec.s0, spec.r0], [1, spec.s1, spec.r1]]) comps.push(spec.ends[end] === "dome" ? 1 : 0, s, r);
  run.components = comps;
  const details = part.details ?? {};
  run.seams = (details.seams ?? []).map((s) => ({ s, r: spec.r0 + g * (s - spec.s0), slope: g }));
  run.ribs = details.ribs ? { ...details.ribs, r0: details.ribs.r0 ?? spec.r0 + g * (details.ribs.s0 - spec.s0), r1: details.ribs.r1 ?? spec.r0 + g * (details.ribs.s1 - spec.s0) } : null;
  run.cutS = null;
  for (const cut of spec.cuts ?? []) {
    const along = cut.n[0] * spec.F.a[0] + cut.n[1] * spec.F.a[1] + cut.n[2] * spec.F.a[2];
    const sCut = (cut.d - (cut.n[0] * spec.F.o[0] + cut.n[1] * spec.F.o[1] + cut.n[2] * spec.F.o[2])) / along;
    if (Math.abs(sCut - spec.s0) < 1e-6 || Math.abs(sCut - spec.s1) < 1e-6) run.cutS = sCut;
  }
}

function t3EmitRound(run, cam, f) {
  const geo = t3Geo(run.F, cam);
  const k = cam.k;
  const step = T3_TAU / run.fine;
  const sheets = ["", "", "", "", ""];
  const { s0, s1, r0, r1 } = run;
  for (const arc of t3Arcs(run.nr, run.na, geo.view, geo.light)) sheets[arc.tone] += t3Strip(geo, s0, r0, s1, r1, arc.from, arc.to, step);
  const F = run.F;
  for (let end = 0; end < 2; end++) {
    if (run.ends[end] !== "dome") continue;
    const s = end ? s1 : s0;
    const sign = end ? 1 : -1;
    const C = [F.o[0] + F.a[0] * s, F.o[1] + F.a[1] * s, F.o[2] + F.a[2] * s];
    t3Dome(cam, C, [F.a[0] * sign, F.a[1] * sign, F.a[2] * sign], end ? r1 : r0, run.domeCount, sheets);
  }
  for (let t = 0; t < 5; t++) f.d[run.role[`s${t}`]] = sheets[t];
  const pts = t3RoundOutline(run, geo, cam);
  const outline = t3PathOf(pts, true);
  f.d[run.role.fill] = outline;
  if (run.cutS !== null) {
    const buf = new Float64Array(2 * pts.length);
    const skip = new Uint8Array(pts.length);
    pts.forEach((p, j) => {
      buf[2 * j] = p[0];
      buf[2 * j + 1] = p[1];
    });
    for (let j = 0; j < pts.length; j++) skip[j] = pts[j].cut && pts[(j + 1) % pts.length].cut ? 1 : 0;
    f.d[run.role.edge] = t3Broken(buf, pts.length, skip);
  } else f.d[run.role.edge] = outline;
  t3BoxOf(pts, f.box);
  t3HullOut(f, pts);
  const view = geo.view;
  let crease = "";
  for (let end = 0; end < 2; end++) {
    if (run.ends[end] !== "flat") continue;
    const role = run.role[`k${end}`];
    const sign = end ? 1 : -1;
    const s = end ? s1 : s0;
    const r = end ? r1 : r0;
    const face = sign * geo.aV;
    if (!(face > 1e-9)) {
      f.d[role] = "";
      continue;
    }
    const n = run.capCount;
    const buf = t3Scratch("c", 2 * n);
    const table = t3Table(n);
    for (let j = 0; j < n; j++) {
      const c = table[2 * j];
      const q = table[2 * j + 1];
      buf[2 * j] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + q * geo.SV[0]);
      buf[2 * j + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + q * geo.SV[1]);
    }
    f.d[role] = t3Path(buf, n, true);
    f.q[role] = t3Level(sign * geo.aL, T3_LB);
    const minor = 2 * r * k * Math.abs(face);
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    if (run.role.crease === undefined) continue;
    let lo;
    let hi;
    if (view.flat < 1e-9) {
      if (!(run.na * view.a > 1e-9)) continue;
      lo = 0;
      hi = T3_TAU;
    } else {
      const cc = -(run.na * view.a) / (run.nr * view.flat);
      if (cc >= 1) continue;
      if (cc <= -1) {
        lo = view.phi;
        hi = view.phi + T3_TAU;
      } else {
        const d = Math.acos(cc);
        lo = view.phi - d;
        hi = view.phi + d;
      }
    }
    const count = Math.min(400, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
    const arc = t3Scratch("c", 2 * count + 2);
    for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, arc, 2 * j);
    crease += t3Path(arc, count + 1, false);
  }
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  if (run.role.inner !== undefined) {
    let inner = "";
    for (const seam of run.seams) inner += t3SeamArc(geo, seam.s, seam.r, seam.slope, k);
    if (run.ribs && run.ribs.seams) {
      const rs = run.ribs;
      const slope = (rs.r1 - rs.r0) / (rs.s1 - rs.s0 || 1);
      if (rs.seams !== "last") inner += t3SeamArc(geo, rs.s0, rs.r0, slope, k);
      if (rs.seams !== "first") inner += t3SeamArc(geo, rs.s1, rs.r1, slope, k);
    }
    f.d[run.role.inner] = inner;
  }
  if (run.ribs) {
    const lines = t3State.detail < 0.01 ? t3None : t3RibLines(geo, run.F, cam, run.ribs, 0, 0);
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
}

function t3PrepareBall(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.c = spec.c;
  run.r = spec.r;
  run.flats = spec.flats.map((flat) => {
    const n = t3Unit(flat.n);
    const rho = Math.sqrt(Math.max(0, spec.r * spec.r - flat.d * flat.d));
    const [u, v] = t3Basis(n);
    return { n, d: flat.d, rho, u, v };
  });
  run.limb = Math.max(32, Math.min(160, Math.ceil((T3_TAU * spec.r * k * unit) / 1.5)));
  run.rimCount = run.flats.map((flat) => Math.max(20, Math.min(120, Math.ceil((T3_TAU * flat.rho * k * unit) / 1.5))));
}

function t3ClipConvex(subject, clip) {
  if (subject.length < 3 || clip.length < 3) return [];
  let area = 0;
  for (let i = 0; i < clip.length; i++) {
    const a = clip[i];
    const b = clip[(i + 1) % clip.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = area >= 0 ? 1 : -1;
  let out = subject;
  for (let i = 0; i < clip.length && out.length; i++) {
    const a = clip[i];
    const b = clip[(i + 1) % clip.length];
    const side = (p) => sign * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]));
    const input = out;
    out = [];
    for (let j = 0; j < input.length; j++) {
      const p = input[j];
      const q = input[(j + 1) % input.length];
      const sp = side(p);
      const sq = side(q);
      if (sp >= 0) out.push(p);
      if ((sp >= 0) !== (sq >= 0)) {
        const t = sp / (sp - sq);
        out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
      }
    }
  }
  return out;
}

function t3EmitBall(run, cam, f) {
  const m = cam.m;
  const V = cam.V;
  const L = cam.L;
  const { c, r, flats } = run;
  const Cx = cam.ox + m[0] * c[0] + m[1] * c[1] + m[2] * c[2];
  const Cy = cam.oy + m[3] * c[0] + m[4] * c[1] + m[5] * c[2];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const flat = (w) => [Cx + r * (m[0] * w[0] + m[1] * w[1] + m[2] * w[2]), Cy + r * (m[3] * w[0] + m[4] * w[1] + m[5] * w[2])];
  const inside = (w, skip = -1) => {
    for (let i = 0; i < flats.length; i++) if (i !== skip && dot(w, flats[i].n) * r > flats[i].d + 1e-7) return false;
    return true;
  };
  const [U, W] = t3Basis(V);
  const n = run.limb;
  const table = t3Table(n);
  const limb = [];
  for (let j = 0; j < n; j++) limb.push([U[0] * table[2 * j] + W[0] * table[2 * j + 1], U[1] * table[2 * j] + W[1] * table[2 * j + 1], U[2] * table[2 * j] + W[2] * table[2 * j + 1]]);
  const sil = [];
  for (const w of limb) if (inside(w)) sil.push(flat(w));
  const rims = flats.map((cut, ci) => {
    const count = run.rimCount[ci];
    const t = t3Table(count);
    const list = [];
    for (let j = 0; j < count; j++) {
      const w = [(cut.n[0] * cut.d + cut.rho * (cut.u[0] * t[2 * j] + cut.v[0] * t[2 * j + 1])) / r, (cut.n[1] * cut.d + cut.rho * (cut.u[1] * t[2 * j] + cut.v[1] * t[2 * j + 1])) / r, (cut.n[2] * cut.d + cut.rho * (cut.u[2] * t[2 * j] + cut.v[2] * t[2 * j + 1])) / r];
      list.push({ w, keep: inside(w, ci), p: flat(w) });
    }
    return list;
  });
  flats.forEach((cut, ci) => {
    for (const w of t3Meet(V, 0, cut.n, cut.d / r)) if (inside(w, ci)) sil.push(flat(w));
    rims[ci].forEach(({ keep, p }) => keep && sil.push(p));
  });
  const hull = t3Hull(sil);
  const outline = t3PathOf(hull, true);
  f.d[run.role.fill] = outline;
  f.d[run.role.edge] = outline;
  f.d[run.role.s0] = outline;
  const capOf = (threshold) => {
    const points = [];
    for (const w of limb) if (dot(w, L) >= threshold && inside(w)) points.push(flat(w));
    const radius = Math.sqrt(Math.max(0, 1 - threshold * threshold));
    const [LU, LW] = t3Basis(L);
    for (let j = 0; j < n; j++) {
      const w = [L[0] * threshold + radius * (LU[0] * table[2 * j] + LW[0] * table[2 * j + 1]), L[1] * threshold + radius * (LU[1] * table[2 * j] + LW[1] * table[2 * j + 1]), L[2] * threshold + radius * (LU[2] * table[2 * j] + LW[2] * table[2 * j + 1])];
      if (dot(w, V) >= -1e-9) points.push(flat(w));
    }
    if (dot(L, V) > threshold) points.push(flat(V));
    for (const w of t3Meet(L, threshold, V, 0)) if (inside(w)) points.push(flat(w));
    flats.forEach((cut, ci) => {
      for (const w of t3Meet(V, 0, cut.n, cut.d / r)) if (inside(w, ci) && dot(w, L) >= threshold) points.push(flat(w));
      for (const w of t3Meet(L, threshold, cut.n, cut.d / r)) if (inside(w, ci) && dot(w, V) >= -1e-9) points.push(flat(w));
      for (const { w, keep, p } of rims[ci]) if (keep && dot(w, L) >= threshold && dot(w, V) >= -1e-9) points.push(p);
    });
    if (points.length < 3) return "";
    const region = t3ClipConvex(t3Hull(points), hull);
    return region.length >= 3 ? t3PathOf(region, true) : "";
  };
  f.d[run.role.s1] = capOf(T3_LB[1]);
  f.d[run.role.s2] = capOf(T3_LB[2]);
  f.d[run.role.s3] = capOf(T3_LB[3]);
  let crease = "";
  flats.forEach((cut, ci) => {
    const role = run.role[`f${ci}`];
    const face = dot(cut.n, V);
    if (!(face > 1e-9)) {
      f.d[role] = "";
      return;
    }
    const pts = t3ClipConvex(rims[ci].map(({ p }) => p), hull);
    f.d[role] = t3PathOf(pts, true);
    f.q[role] = t3Level(dot(cut.n, L), T3_LB);
    const minor = 2 * cut.rho * cam.k * face;
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    const list = rims[ci];
    const seen = list.map(({ w, keep }) => keep && dot(w, V) > 0);
    const start = seen.indexOf(false);
    if (start < 0) {
      crease += t3PathOf(list.map(({ p }) => p), true);
      return;
    }
    let cur = null;
    for (let s = 1; s <= list.length; s++) {
      const i = (start + s) % list.length;
      if (seen[i]) (cur ??= []).push(list[i].p);
      else if (cur) {
        if (cur.length > 1) crease += t3PathOf(cur, false);
        cur = null;
      }
    }
    if (cur && cur.length > 1) crease += t3PathOf(cur, false);
  });
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  let x0 = Infinity;
  let y0 = Infinity;
  let x1 = -Infinity;
  let y1 = -Infinity;
  for (const [x, y] of hull) {
    if (x < x0) x0 = x;
    if (x > x1) x1 = x;
    if (y < y0) y0 = y;
    if (y > y1) y1 = y;
  }
  f.box[0] = x0;
  f.box[1] = y0;
  f.box[2] = x1;
  f.box[3] = y1;
  t3HullOut(f, hull);
}

function t3PrepareLathe(run, part, P, unit) {
  const spec = part.spec;
  const k = P.scale * T3_ROOT;
  run.F = spec.F;
  run.levels = spec.levels;
  run.caps = spec.caps;
  let rmax = 0;
  for (let i = 0; i < spec.levels.length; i += 4) rmax = Math.max(rmax, spec.levels[i + 1]);
  run.fine = Math.max(24, Math.min(96, Math.ceil((T3_TAU * rmax * k * unit) / 2.2)));
  run.dirs = Math.max(32, Math.min(256, Math.ceil((T3_TAU * rmax * k * unit) / 1.5)));
  run.capCount = Math.max(24, Math.min(160, Math.ceil((T3_TAU * rmax * k * unit) / 1.5)));
  const comps = [];
  for (let i = 0; i < spec.levels.length; i += 4) comps.push(0, spec.levels[i], spec.levels[i + 1]);
  run.components = comps;
  const details = part.details ?? {};
  run.seams = (details.seams ?? []).map((seam) => (typeof seam === "number" ? t3LatheSeam(spec.levels, seam) : seam));
  run.ribs = details.ribs ?? null;
}

function t3LatheSeam(levels, s) {
  for (let i = 0; i + 4 < levels.length; i += 4) {
    const sa = levels[i];
    const sb = levels[i + 4];
    if ((s - sa) * (s - sb) <= 0 && sa !== sb) {
      const t = (s - sa) / (sb - sa);
      const r = levels[i + 1] + (levels[i + 5] - levels[i + 1]) * t;
      const slope = (levels[i + 5] - levels[i + 1]) / (sb - sa);
      return { s, r, slope };
    }
  }
  return { s, r: levels[1], slope: 0 };
}

function t3LatheSheets(levels, geo, step, buckets) {
  const at = (L, t) => {
    const c = Math.cos(t);
    const n = Math.sin(t);
    return [geo.O[0] + L.s * geo.SA[0] + L.r * (c * geo.SU[0] + n * geo.SV[0]), geo.O[1] + L.s * geo.SA[1] + L.r * (c * geo.SU[1] + n * geo.SV[1])];
  };
  const arcPoints = (L, from, to) => {
    const count = Math.max(1, Math.ceil((to - from) / step));
    const out = new Array(count + 1);
    for (let index = 0; index <= count; index++) out[index] = at(L, from + ((to - from) * index) / count);
    return out;
  };
  const oriented = (points) => {
    let area = 0;
    for (let i = 0; i < points.length; i++) {
      const a = points[i];
      const b = points[(i + 1) % points.length];
      area += a[0] * b[1] - b[0] * a[1];
    }
    return area < 0 ? points.reverse() : points;
  };
  const centreOf = (arc, reference) => {
    let mid = (arc.from + arc.to) / 2 - reference;
    mid = ((mid % T3_TAU) + T3_TAU) % T3_TAU;
    return mid;
  };
  const present = [false, false, false, false, false];
  for (const L of levels) {
    const groups = [null, null, null, null, null];
    for (const arc of L.arcs) {
      if (!groups[arc.tone]) groups[arc.tone] = [];
      groups[arc.tone].push(arc);
      present[arc.tone] = true;
    }
    for (const list of groups) if (list && list.length > 1) list.sort((x, y) => centreOf(x, geo.light.phi) - centreOf(y, geo.light.phi));
    L.groups = groups;
  }
  const align = (reference, arc) => {
    const out = { ...arc };
    const centre = (reference.from + reference.to) / 2;
    while ((out.from + out.to) / 2 - centre > Math.PI) {
      out.from -= T3_TAU;
      out.to -= T3_TAU;
    }
    while (centre - (out.from + out.to) / 2 > Math.PI) {
      out.from += T3_TAU;
      out.to += T3_TAU;
    }
    return out;
  };
  const overlap = (x, y) => Math.min(x.to, y.to) - Math.max(x.from, y.from);
  const sheet = (tone, run) => {
    const chains = run[0].groups[tone].map((arc) => [arc]);
    for (let q = 1; q < run.length; q++) {
      const next = run[q].groups[tone];
      const used = new Uint8Array(next.length);
      for (const chain of chains) {
        const prev = chain[q - 1];
        let best = -1;
        let score = -Infinity;
        for (let j = 0; j < next.length; j++) {
          if (used[j]) continue;
          const value = overlap(prev, align(prev, next[j]));
          if (value > score) {
            score = value;
            best = j;
          }
        }
        used[best] = 1;
        chain.push(align(prev, next[best]));
      }
    }
    chains.forEach((chain) => {
      const last = run.length - 1;
      const points = arcPoints(run[0], chain[0].from, chain[0].to);
      for (let q = 1; q < last; q++) points.push(at(run[q], chain[q].to));
      points.push(...arcPoints(run[last], chain[last].from, chain[last].to).reverse());
      for (let q = last - 1; q >= 1; q--) points.push(at(run[q], chain[q].from));
      buckets[tone] += t3PathOf(oriented(points), true);
    });
  };
  const strip = (tone, a, b) => {
    const below = a.groups[tone] ?? [];
    const above = b.groups[tone] ?? [];
    const whole = (arc) => arc.to - arc.from >= T3_TAU - 1e-6;
    const seat = (ring, arc) => {
      const gap = T3_TAU - (arc.to - arc.from);
      return { from: arc.from - gap / 2, to: arc.to + gap / 2 };
    };
    const quad = (lo, hi) => {
      if (whole(hi) && !whole(lo)) hi = seat(hi, lo);
      else if (whole(lo) && !whole(hi)) lo = seat(lo, hi);
      const points = [...arcPoints(a, lo.from, lo.to), ...arcPoints(b, hi.from, hi.to).reverse()];
      buckets[tone] += t3PathOf(oriented(points), true);
    };
    const links = below.map(() => []);
    const back = above.map(() => []);
    below.forEach((lo, i) =>
      above.forEach((hi, j) => {
        if (overlap(lo, align(lo, hi)) > -0.05) {
          links[i].push(j);
          back[j].push(i);
        }
      }),
    );
    const doneLo = new Uint8Array(below.length);
    const doneHi = new Uint8Array(above.length);
    const share = (span, parts, flip) => {
      if (whole(span)) {
        const base = parts[0].from;
        const sorted = parts.map((arc) => {
          const shift = T3_TAU * Math.floor((arc.from - base) / T3_TAU);
          return { from: arc.from - shift, to: arc.to - shift };
        }).sort((x, y) => x.from - y.from);
        const n = sorted.length;
        const cuts = sorted.map((arc, q) => {
          const next = q + 1 < n ? sorted[q + 1].from : sorted[0].from + T3_TAU;
          return (arc.to + next) / 2;
        });
        sorted.forEach((arc, q) => {
          const piece = { from: q ? cuts[q - 1] : cuts[n - 1] - T3_TAU, to: cuts[q] };
          if (flip) quad(arc, piece);
          else quad(piece, arc);
        });
        return;
      }
      const sorted = parts.map((arc) => align(span, arc)).sort((x, y) => x.from - y.from);
      const cuts = [span.from];
      for (let q = 1; q < sorted.length; q++) cuts.push(Math.min(span.to, Math.max(span.from, (sorted[q - 1].to + sorted[q].from) / 2)));
      cuts.push(span.to);
      sorted.forEach((arc, q) => {
        const piece = { from: cuts[q], to: cuts[q + 1] };
        if (flip) quad(arc, piece);
        else quad(piece, arc);
      });
    };
    for (let j = 0; j < above.length; j++) {
      if (back[j].length < 2 || back[j].some((i) => links[i].length !== 1)) continue;
      share(above[j], back[j].map((i) => below[i]), false);
      doneHi[j] = 1;
      for (const i of back[j]) doneLo[i] = 1;
    }
    for (let i = 0; i < below.length; i++) {
      if (doneLo[i] || links[i].length < 2 || links[i].some((j) => back[j].length !== 1)) continue;
      share(below[i], links[i].map((j) => above[j]), true);
      doneLo[i] = 1;
      for (const j of links[i]) doneHi[j] = 1;
    }
    for (let i = 0; i < below.length; i++) {
      if (doneLo[i]) continue;
      const lo = below[i];
      let best = -1;
      let score = -Infinity;
      for (const j of links[i]) {
        if (doneHi[j]) continue;
        const value = overlap(lo, align(lo, above[j]));
        if (value > score) {
          score = value;
          best = j;
        }
      }
      if (best >= 0) {
        doneHi[best] = 1;
        quad(lo, align(lo, above[best]));
      } else {
        const centre = (lo.from + lo.to) / 2;
        quad(lo, { from: centre, to: centre });
      }
    }
    for (let j = 0; j < above.length; j++) {
      if (doneHi[j]) continue;
      const centre = (above[j].from + above[j].to) / 2;
      quad({ from: centre, to: centre }, above[j]);
    }
  };
  for (let tone = 0; tone < 5; tone++) {
    if (!present[tone]) continue;
    for (let q = 1; q < levels.length; q++) strip(tone, levels[q - 1], levels[q]);
  }
  void sheet;
}

function t3EmitLathe(run, cam, f) {
  const geo = t3Geo(run.F, cam);
  const k = cam.k;
  const step = T3_TAU / run.fine;
  const sheets = ["", "", "", "", ""];
  const lv = run.levels;
  const levelAt = (s, r, nr, na) => ({ s, r, nr, na, arcs: t3Arcs(nr, na, geo.view, geo.light) });
  const same = (a, b) => {
    if (a.arcs.length !== b.arcs.length) return false;
    for (let i = 0; i < a.arcs.length; i++) if (a.arcs[i].tone !== b.arcs[i].tone || Boolean(a.arcs[i].full) !== Boolean(b.arcs[i].full)) return false;
    return true;
  };
  const reach = (a, b) => Math.hypot((b.s - a.s) * Math.hypot(geo.SA[0], geo.SA[1]), (b.r - a.r) * k) + Math.abs(b.r - a.r) * k;
  const levels = [];
  const wrap = (x, ref) => x + T3_TAU * Math.round((ref - x) / T3_TAU);
  const close = (a, mid, b) => {
    const r = Math.max(a.r, b.r) * k;
    for (let i = 0; i < mid.arcs.length; i++) {
      const m = mid.arcs[i];
      if (m.full) continue;
      for (const key of ["from", "to"]) {
        const x = m[key];
        const guess = (wrap(a.arcs[i][key], x) + wrap(b.arcs[i][key], x)) / 2;
        if (Math.abs(guess - x) * r > 0.25) return false;
      }
    }
    return true;
  };
  const refine = (a, b, depth) => {
    if (depth >= 8 || reach(a, b) < 0.1) {
      levels.push(b);
      return;
    }
    const m = t3LatheMid(a, b);
    const mid = levelAt(m.s, m.r, m.nr, m.na);
    if (same(a, b) && same(a, mid) && close(a, mid, b)) {
      levels.push(b);
      return;
    }
    refine(a, mid, depth + 1);
    refine(mid, b, depth + 1);
  };
  let previous = levelAt(lv[0], lv[1], lv[2], lv[3]);
  levels.push(previous);
  for (let i = 4; i < lv.length; i += 4) {
    const next = levelAt(lv[i], lv[i + 1], lv[i + 2], lv[i + 3]);
    refine(previous, next, 0);
    previous = next;
  }
  t3LatheSheets(levels, geo, step, sheets);
  for (let t = 0; t < 5; t++) f.d[run.role[`s${t}`]] = sheets[t];
  const pts = t3LatheOutline(run, geo, cam);
  const outline = t3PathOf(pts, true);
  f.d[run.role.fill] = outline;
  f.d[run.role.edge] = outline;
  t3BoxOf(pts, f.box);
  t3HullOut(f, pts);
  let crease = "";
  const view = geo.view;
  const ends = [[0, lv[0], lv[1], -1, 4], [1, lv[lv.length - 4], lv[lv.length - 3], 1, lv.length - 8]];
  for (const [end, s, r, sign, near] of ends) {
    const role = run.role[`k${end}`];
    if (role === undefined) continue;
    const face = sign * geo.aV;
    if (!(face > 1e-9) || r <= 1e-9) {
      f.d[role] = "";
      continue;
    }
    const n = run.capCount;
    const buf = t3Scratch("c", 2 * n);
    const table = t3Table(n);
    for (let j = 0; j < n; j++) {
      const c = table[2 * j];
      const q = table[2 * j + 1];
      buf[2 * j] = geo.O[0] + s * geo.SA[0] + r * (c * geo.SU[0] + q * geo.SV[0]);
      buf[2 * j + 1] = geo.O[1] + s * geo.SA[1] + r * (c * geo.SU[1] + q * geo.SV[1]);
    }
    f.d[role] = t3Path(buf, n, true);
    f.q[role] = t3Level(sign * geo.aL, T3_LB);
    const minor = 2 * r * k * Math.abs(face);
    f.w[role] = minor < 0.4 ? Math.round(minor * 100) / 100 : -1;
    if (run.role.crease === undefined) continue;
    const nr = lv[near + 2];
    const na = lv[near + 3];
    let lo;
    let hi;
    if (view.flat < 1e-9) {
      if (!(na * view.a > 1e-9)) continue;
      lo = 0;
      hi = T3_TAU;
    } else {
      const cc = -(na * view.a) / (nr * view.flat);
      if (cc >= 1) continue;
      if (cc <= -1) {
        lo = view.phi;
        hi = view.phi + T3_TAU;
      } else {
        const d = Math.acos(cc);
        lo = view.phi - d;
        hi = view.phi + d;
      }
    }
    const count = Math.min(400, Math.max(4, Math.ceil(((hi - lo) * r * k) / 1.5)));
    const arc = t3Scratch("c", 2 * count + 2);
    for (let j = 0; j <= count; j++) t3At(geo, s, r, lo + ((hi - lo) * j) / count, arc, 2 * j);
    crease += t3Path(arc, count + 1, false);
  }
  if (run.role.crease !== undefined) f.d[run.role.crease] = crease;
  if (run.role.inner !== undefined) {
    let inner = "";
    for (const seam of run.seams) inner += t3SeamArc(geo, seam.s, seam.r, seam.slope, k);
    f.d[run.role.inner] = inner;
  }
  if (run.ribs) {
    const lines = t3State.detail < 0.01 ? t3None : t3RibLines(geo, run.F, cam, run.ribs, 0, 0);
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
}

const T3_SHINE = (() => {
  const l = Math.hypot(-0.55, -0.835);
  return [-0.55 / l, -0.835 / l];
})();

function t3RouteOf(flat) {
  const n = flat.length / 3;
  const along = new Float64Array(n);
  for (let i = 1; i < n; i++) along[i] = along[i - 1] + Math.hypot(flat[3 * i] - flat[3 * i - 3], flat[3 * i + 1] - flat[3 * i - 2], flat[3 * i + 2] - flat[3 * i - 1]);
  return { flat, n, along, total: along[n - 1] };
}

function t3PointAlong(route, at) {
  const { flat, n, along } = route;
  if (at <= 0) return [flat[0], flat[1], flat[2]];
  if (at >= along[n - 1]) return [flat[3 * n - 3], flat[3 * n - 2], flat[3 * n - 1]];
  let lo = 0;
  let hi = n - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (along[mid] <= at) lo = mid;
    else hi = mid;
  }
  const span = along[hi] - along[lo] || 1;
  const t = (at - along[lo]) / span;
  return [flat[3 * lo] + (flat[3 * hi] - flat[3 * lo]) * t, flat[3 * lo + 1] + (flat[3 * hi + 1] - flat[3 * lo + 1]) * t, flat[3 * lo + 2] + (flat[3 * hi + 2] - flat[3 * lo + 2]) * t];
}

function t3Cut(route, from, to) {
  const { flat, n, along } = route;
  const out = [];
  for (let i = 1; i < n; i++) {
    const start = along[i - 1];
    const end = along[i];
    if (end >= from && start <= to) {
      const length = end - start || 1;
      const t0 = Math.max(0, (from - start) / length);
      const t1 = Math.min(1, (to - start) / length);
      const at = (t) => [flat[3 * i - 3] + (flat[3 * i] - flat[3 * i - 3]) * t, flat[3 * i - 2] + (flat[3 * i + 1] - flat[3 * i - 2]) * t, flat[3 * i - 1] + (flat[3 * i + 2] - flat[3 * i - 1]) * t];
      if (!out.length) out.push(at(t0));
      out.push(at(t1));
    }
  }
  return out;
}

function t3TangentAlong(route, at) {
  const a = t3PointAlong(route, Math.max(0, at - 0.6));
  const b = t3PointAlong(route, Math.min(route.total, at + 0.6));
  return t3Unit([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
}

const t3RouteCache = new WeakMap();

function t3PrepareTube(run, part, data) {
  const spec = part.spec;
  const source = data.routes[spec.route];
  let route = t3RouteCache.get(source);
  if (!route) {
    route = t3RouteOf(source.points);
    t3RouteCache.set(source, route);
  }
  const total = route.total;
  const r = source.r;
  const step = Math.max(0.05, Math.min(source.spacing, total / 4) * 0.5);
  const lead = 0.5;
  run.r = r;
  run.wide = Boolean(source.wide);
  run.hue = source.hue ?? null;
  const partOf = (start, end) => {
    const points = t3Cut(route, Math.max(0, start), Math.min(total, end));
    let walked = Math.max(0, start);
    const count = points.length;
    const P3 = new Float64Array(3 * count);
    const D3 = new Float64Array(3 * count);
    for (let i = 0; i < count; i++) {
      if (i > 0) walked += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1], points[i][2] - points[i - 1][2]);
      const a = t3PointAlong(route, Math.max(0, Math.min(total, walked - step)));
      const b = t3PointAlong(route, Math.max(0, Math.min(total, walked + step)));
      for (let c = 0; c < 3; c++) {
        P3[3 * i + c] = points[i][c];
        D3[3 * i + c] = b[c] - a[c];
      }
    }
    return { count, P3, D3 };
  };
  const openStart = spec.open0 && spec.from > 0;
  const openEnd = spec.open1 && spec.to < total;
  run.body = partOf(openStart ? spec.from - lead : spec.from, openEnd ? spec.to + lead : spec.to);
  run.stripe = openStart || openEnd ? partOf(openStart ? spec.from - 2 * lead : spec.from, openEnd ? spec.to + 2 * lead : spec.to) : run.body;
  const ends = [!spec.open0 && (spec.from <= 1e-6 || spec.gap0), !spec.open1 && (spec.to >= total - 1e-6 || spec.gap1)];
  run.capped = [ends[0] && (spec.gap0 || spec.caps[0]), ends[1] && (spec.gap1 || spec.caps[1])];
  const P3 = run.body.P3;
  const last = run.body.count - 1;
  const vec = (i, j) => t3Unit([P3[3 * i] - P3[3 * j], P3[3 * i + 1] - P3[3 * j + 1], P3[3 * i + 2] - P3[3 * j + 2]]);
  run.tips = [vec(1, 0), vec(last, last - 1)];
  run.rings = null;
  if (source.rings) {
    const { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16 } = source.rings;
    const lo = openStart ? spec.from - 2 * lead : spec.from;
    const hi = openEnd ? spec.to + 2 * lead : spec.to;
    const margin = twist ? 0 : Math.min(pitch * 0.35, (hi - lo) / 2);
    const from = Math.max(lo, !openStart ? lo + margin : lo);
    const to = Math.min(hi, !openEnd ? hi - margin : hi);
    const segments = [];
    if (to > from) {
      const hands = twist ? (cross ? [1, -1] : [1]) : [0];
      const reach = Math.abs(twist) * r;
      const first = Math.ceil((from - reach - phase) / pitch);
      const lastIndex = Math.floor((to + reach - phase) / pitch);
      const surface = (t, theta) => {
        const at = Math.max(0, Math.min(total, t));
        return { c: t3PointAlong(route, at), T: t3TangentAlong(route, at), theta };
      };
      for (let index = first; index <= lastIndex; index++) {
        const station = phase + index * pitch;
        for (const hand of hands) {
          const samples = [];
          for (let k = 0; k <= steps; k++) {
            const theta = (k / steps) * Math.PI;
            const t = station + hand * twist * r * (k / steps - 0.5);
            samples.push({ t, theta });
          }
          for (let k = 0; k < steps; k++) {
            let a = samples[k];
            let b = samples[k + 1];
            if ((a.t < from && b.t < from) || (a.t > to && b.t > to)) continue;
            const clipTo = (x, y, edge) => ({ t: edge, theta: x.theta + ((y.theta - x.theta) * (edge - x.t)) / (y.t - x.t || 1e-9) });
            if (a.t < from) a = clipTo(a, b, from);
            if (b.t < from) b = clipTo(b, a, from);
            if (a.t > to) a = clipTo(a, b, to);
            if (b.t > to) b = clipTo(b, a, to);
            segments.push([surface(a.t, a.theta), surface(b.t, b.theta)]);
          }
        }
      }
    }
    run.rings = { segments, fade, tone: source.rings.tone ?? "lo" };
  }
}

function t3Sides(part, cam, r) {
  const m = cam.m;
  const k = cam.k;
  const n = part.count;
  const out = { n, flat: new Float64Array(2 * n), left: new Float64Array(2 * n), right: new Float64Array(2 * n), shineA: new Float64Array(2 * n), shineB: new Float64Array(2 * n), shadeA: new Float64Array(2 * n), shadeB: new Float64Array(2 * n), frames: [] };
  const half = r * k;
  for (let i = 0; i < n; i++) {
    const x = part.P3[3 * i];
    const y = part.P3[3 * i + 1];
    const z = part.P3[3 * i + 2];
    const px = cam.ox + m[0] * x + m[1] * y + m[2] * z;
    const py = cam.oy + m[3] * x + m[4] * y + m[5] * z;
    const dx = m[0] * part.D3[3 * i] + m[1] * part.D3[3 * i + 1] + m[2] * part.D3[3 * i + 2];
    const dy = m[3] * part.D3[3 * i] + m[4] * part.D3[3 * i + 1] + m[5] * part.D3[3 * i + 2];
    const l = Math.hypot(dx, dy) || 1;
    const nx = -dy / l;
    const ny = dx / l;
    out.flat[2 * i] = px;
    out.flat[2 * i + 1] = py;
    out.frames.push({ p: [px, py], nx, ny, half });
    out.left[2 * i] = px + nx * half;
    out.left[2 * i + 1] = py + ny * half;
    out.right[2 * i] = px - nx * half;
    out.right[2 * i + 1] = py - ny * half;
    const toward = nx * T3_SHINE[0] + ny * T3_SHINE[1];
    const shineAt = half * 0.44 * toward;
    const shineHalf = half * 0.24;
    out.shineA[2 * i] = px + nx * (shineAt + shineHalf);
    out.shineA[2 * i + 1] = py + ny * (shineAt + shineHalf);
    out.shineB[2 * i] = px + nx * (shineAt - shineHalf);
    out.shineB[2 * i + 1] = py + ny * (shineAt - shineHalf);
    const shadeAt = -half * 0.62 * toward;
    const shadeHalf = half * 0.2;
    out.shadeA[2 * i] = px + nx * (shadeAt + shadeHalf);
    out.shadeA[2 * i + 1] = py + ny * (shadeAt + shadeHalf);
    out.shadeB[2 * i] = px + nx * (shadeAt - shadeHalf);
    out.shadeB[2 * i + 1] = py + ny * (shadeAt - shadeHalf);
  }
  return out;
}

const t3Pts = (buf, n) => {
  const out = new Array(n);
  for (let i = 0; i < n; i++) out[i] = [buf[2 * i], buf[2 * i + 1]];
  return out;
};

function t3TubeCap(cam, centre, tangent, radius, inward) {
  const V = cam.V;
  const m = cam.m;
  let n1 = [tangent[1] * V[2] - tangent[2] * V[1], tangent[2] * V[0] - tangent[0] * V[2], tangent[0] * V[1] - tangent[1] * V[0]];
  if (Math.hypot(n1[0], n1[1], n1[2]) < 1e-6) {
    const helper = Math.abs(tangent[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
    n1 = [helper[1] * tangent[2] - helper[2] * tangent[1], helper[2] * tangent[0] - helper[0] * tangent[2], helper[0] * tangent[1] - helper[1] * tangent[0]];
  }
  n1 = t3Unit(n1);
  const n2 = t3Unit([tangent[1] * n1[2] - tangent[2] * n1[1], tangent[2] * n1[0] - tangent[0] * n1[2], tangent[0] * n1[1] - tangent[1] * n1[0]]);
  const steps = 28;
  const proj = (p) => [cam.ox + m[0] * p[0] + m[1] * p[1] + m[2] * p[2], cam.oy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2]];
  const ring = new Array(steps);
  for (let index = 0; index < steps; index++) {
    const angle = (index / steps) * T3_TAU;
    const cs = radius * Math.cos(angle);
    const sn = radius * Math.sin(angle);
    ring[index] = proj([centre[0] + n1[0] * cs + n2[0] * sn, centre[1] + n1[1] * cs + n2[1] * sn, centre[2] + n1[2] * cs + n2[2] * sn]);
  }
  const C = proj(centre);
  const probe = proj([centre[0] + n2[0], centre[1] + n2[1], centre[2] + n2[2]]);
  const along = (probe[0] - C[0]) * inward[0] + (probe[1] - C[1]) * inward[1];
  const from = along > 0 ? steps / 2 : 0;
  const half = (start) => Array.from({ length: steps / 2 + 1 }, (_, index) => ring[(start + index) % steps]);
  return { ring, arc: half(from), inner: half((from + steps / 2) % steps) };
}

function t3StripeEnd(half, centre, nx, ny, from, to) {
  const offset = (p) => (p[0] - centre[0]) * nx + (p[1] - centre[1]) * ny;
  const values = half.map(offset);
  const lo = Math.min(from, to);
  const hi = Math.max(from, to);
  const points = [];
  const cross = (k, level) => {
    const t = (level - values[k]) / (values[k + 1] - values[k] || 1e-9);
    return [half[k][0] + (half[k + 1][0] - half[k][0]) * t, half[k][1] + (half[k + 1][1] - half[k][1]) * t];
  };
  for (let k = 0; k < half.length; k++) {
    if (values[k] >= lo && values[k] <= hi) points.push(half[k]);
    if (k < half.length - 1) for (const level of [lo, hi]) if ((values[k] - level) * (values[k + 1] - level) < 0) points.push(cross(k, level));
  }
  return points
    .map((p) => [p, offset(p)])
    .sort((x, y) => (from > to ? y[1] - x[1] : x[1] - y[1]))
    .map(([p]) => p);
}

function t3EmitTube(run, cam, f) {
  const r = run.r;
  const sides = t3Sides(run.body, cam, r);
  const bands = run.stripe === run.body ? sides : t3Sides(run.stripe, cam, r);
  const n = sides.n;
  const left = t3Pts(sides.left, n);
  const right = t3Pts(sides.right, n);
  const loop = left.concat(right.slice().reverse());
  let area = 0;
  for (let i = 0; i < loop.length; i++) {
    const a = loop[i];
    const b = loop[(i + 1) % loop.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sign = Math.sign(area);
  let body = t3PathOf(loop, true);
  let edges = t3PathOf(left, false) + t3PathOf(right, false);
  const tips = [null, null];
  const last = n - 1;
  const quads = [];
  for (let i = 0; i < last; i++) quads.push([left[i], left[i + 1], right[i + 1], right[i]]);
  for (const side of [0, 1]) {
    if (!run.capped[side]) continue;
    const at = side ? last : 0;
    const other = side ? last - 1 : 1;
    const tangent = run.tips[side];
    const outward = side ? tangent : [-tangent[0], -tangent[1], -tangent[2]];
    const inward = [sides.flat[2 * other] - sides.flat[2 * at], sides.flat[2 * other + 1] - sides.flat[2 * at + 1]];
    const centre = [run.body.P3[3 * at], run.body.P3[3 * at + 1], run.body.P3[3 * at + 2]];
    const { ring, arc, inner } = t3TubeCap(cam, centre, tangent, r, inward);
    let ringArea = 0;
    for (let i = 0; i < ring.length; i++) {
      const a = ring[i];
      const b = ring[(i + 1) % ring.length];
      ringArea += a[0] * b[1] - b[0] * a[1];
    }
    body += t3PathOf(Math.sign(ringArea) === sign ? ring : ring.slice().reverse(), true);
    edges += t3PathOf(arc, false);
    const V = cam.V;
    const open = outward[0] * V[0] + outward[1] * V[1] + outward[2] * V[2] > 0;
    if (open) edges += t3PathOf(inner, false);
    tips[side] = { half: open ? inner : arc, frame: sides.frames[at] };
    quads.push(ring);
  }
  f.d[run.role.body] = body;
  f.d[run.role.edges] = edges;
  if (run.wide) {
    const toward = (frame) => frame.nx * T3_SHINE[0] + frame.ny * T3_SHINE[1];
    const shineOffsets = (frame) => [frame.half * 0.44 * toward(frame) + frame.half * 0.24, frame.half * 0.44 * toward(frame) - frame.half * 0.24];
    const shadeOffsets = (frame) => [-frame.half * 0.62 * toward(frame) + frame.half * 0.2, -frame.half * 0.62 * toward(frame) - frame.half * 0.2];
    const bn = bands.n;
    const band = (A, B, offsets) => {
      const head = tips[1] ? t3StripeEnd(tips[1].half, tips[1].frame.p, tips[1].frame.nx, tips[1].frame.ny, offsets(tips[1].frame)[0], offsets(tips[1].frame)[1]) : [];
      const tail = tips[0] ? t3StripeEnd(tips[0].half, tips[0].frame.p, tips[0].frame.nx, tips[0].frame.ny, offsets(tips[0].frame)[1], offsets(tips[0].frame)[0]) : [];
      return t3PathOf([...t3Pts(A, bn), ...head, ...t3Pts(B, bn).reverse(), ...tail], true);
    };
    f.d[run.role.shine] = band(bands.shineA, bands.shineB, shineOffsets);
    f.d[run.role.shade] = band(bands.shadeA, bands.shadeB, shadeOffsets);
  }
  if (run.rings) {
    const lines = t3Lines;
    lines.d.length = 0;
    lines.alpha.length = 0;
  lines.key.length = 0;
    lines.key.length = 0;
    const V = cam.V;
    const m = cam.m;
    const seg = new Float64Array(4);
    const surface = (sample, out, index) => {
      const T = sample.T;
      let n1 = [T[1] * V[2] - T[2] * V[1], T[2] * V[0] - T[0] * V[2], T[0] * V[1] - T[1] * V[0]];
      const l1 = Math.hypot(n1[0], n1[1], n1[2]);
      if (l1 < 1e-6) return null;
      n1 = [n1[0] / l1, n1[1] / l1, n1[2] / l1];
      let n2 = t3Unit([T[1] * n1[2] - T[2] * n1[1], T[2] * n1[0] - T[0] * n1[2], T[0] * n1[1] - T[1] * n1[0]]);
      if (n2[0] * V[0] + n2[1] * V[1] + n2[2] * V[2] < 0) n2 = [-n2[0], -n2[1], -n2[2]];
      const cs = Math.cos(sample.theta);
      const sn = Math.sin(sample.theta);
      const normal = [n1[0] * cs + n2[0] * sn, n1[1] * cs + n2[1] * sn, n1[2] * cs + n2[2] * sn];
      const p = [sample.c[0] + normal[0] * r, sample.c[1] + normal[1] * r, sample.c[2] + normal[2] * r];
      out[index] = cam.ox + m[0] * p[0] + m[1] * p[1] + m[2] * p[2];
      out[index + 1] = cam.oy + m[3] * p[0] + m[4] * p[1] + m[5] * p[2];
      return normal[0] * V[0] + normal[1] * V[1] + normal[2] * V[2];
    };
    const fade = run.rings.fade;
    let index = -1;
    for (const [a, b] of run.rings.segments) {
      index++;
      const fa = surface(a, seg, 0);
      const fb = surface(b, seg, 2);
      if (fa === null || fb === null) continue;
      const alpha = fade ? t3Step(fade[0], fade[1], (fa + fb) / 2) : 1;
      if (alpha * T3_LEVELS < 0.5) continue;
      lines.d.push(t3Path(seg, 2, false));
      lines.alpha.push(alpha);
      lines.key.push(index);
    }
    f.levels = t3Faded(f, run, "r", lines, lines.d.length);
  }
  const all = left.concat(right);
  t3BoxOf(all, f.box);
  for (const q of quads) for (const p of q) {
    if (p[0] < f.box[0]) f.box[0] = p[0];
    if (p[1] < f.box[1]) f.box[1] = p[1];
    if (p[0] > f.box[2]) f.box[2] = p[0];
    if (p[1] > f.box[3]) f.box[3] = p[1];
  }
  const list = f.quadList && f.quadList.length === quads.length ? f.quadList : (f.quadList = quads.map(() => [new Float64Array(0), 0]));
  for (let qi = 0; qi < quads.length; qi++) {
    const q = quads[qi];
    let entry = list[qi];
    if (entry[0].length < 2 * q.length) entry[0] = new Float64Array(2 * q.length);
    const buf = entry[0];
    for (let i = 0; i < q.length; i++) {
      buf[2 * i] = q[i][0];
      buf[2 * i + 1] = q[i][1];
    }
    entry[1] = q.length;
  }
  f.quadN = quads.length;
  f.quads = f.quadList;
  f.hullN = 0;
}

function t3Emit(run, cam, out) {
  const f = out ?? t3Frame(run);
  if (run.kind === "prism") t3EmitPrism(run, cam, f);
  else if (run.kind === "round") t3EmitRound(run, cam, f);
  else if (run.kind === "ball") t3EmitBall(run, cam, f);
  else if (run.kind === "lathe") t3EmitLathe(run, cam, f);
  else if (run.kind === "tube") {
    t3EmitTube(run, cam, f);
    return f;
  } else if (run.kind === "fixed") {
    t3EmitFixed(run, cam, f);
    return f;
  }
  t3EmitPlanes(run, cam, f);
  t3EmitDots(run, cam, f);
  return f;
}

function t3Overlap(a, b, pad) {
  return a[0] < b[2] - pad && b[0] < a[2] - pad && a[1] < b[3] - pad && b[1] < a[3] - pad;
}

function t3Apart(A, an, B, bn, pad) {
  for (let pass = 0; pass < 2; pass++) {
    const P = pass ? B : A;
    const pn = pass ? bn : an;
    for (let i = 0; i < pn; i++) {
      const j = (i + 1) % pn;
      const nx = P[2 * j + 1] - P[2 * i + 1];
      const ny = P[2 * i] - P[2 * j];
      const l = Math.hypot(nx, ny);
      if (l < 1e-9) continue;
      let a0 = Infinity;
      let a1 = -Infinity;
      for (let q = 0; q < an; q++) {
        const v = (A[2 * q] * nx + A[2 * q + 1] * ny) / l;
        if (v < a0) a0 = v;
        if (v > a1) a1 = v;
      }
      let b0 = Infinity;
      let b1 = -Infinity;
      for (let q = 0; q < bn; q++) {
        const v = (B[2 * q] * nx + B[2 * q + 1] * ny) / l;
        if (v < b0) b0 = v;
        if (v > b1) b1 = v;
      }
      if (a1 < b0 + pad || b1 < a0 + pad) return true;
    }
  }
  return false;
}

function t3Disjoint(fa, fb, pad) {
  const qa = fa.quadN ? fa.quads : null;
  const qb = fb.quadN ? fb.quads : null;
  if (!qa && !qb) return t3Apart(fa.hull, fa.hullN, fb.hull, fb.hullN, pad);
  const listA = qa ? fa.quadList : [[fa.hull, fa.hullN]];
  const listB = qb ? fb.quadList : [[fb.hull, fb.hullN]];
  for (const [A, an] of listA) for (const [B, bn] of listB) if (!t3Apart(A, an, B, bn, pad)) return false;
  return true;
}

function t3Support(cloud, R, t, dir, world) {
  let best = -Infinity;
  let at = 0;
  const n = cloud.length / 3;
  const dx = R[0] * dir[0] + R[3] * dir[1] + R[6] * dir[2];
  const dy = R[1] * dir[0] + R[4] * dir[1] + R[7] * dir[2];
  const dz = R[2] * dir[0] + R[5] * dir[1] + R[8] * dir[2];
  for (let i = 0; i < n; i++) {
    const v = cloud[3 * i] * dx + cloud[3 * i + 1] * dy + cloud[3 * i + 2] * dz;
    if (v > best) {
      best = v;
      at = i;
    }
  }
  const x = cloud[3 * at];
  const y = cloud[3 * at + 1];
  const z = cloud[3 * at + 2];
  world[0] = R[0] * x + R[1] * y + R[2] * z + t[0];
  world[1] = R[3] * x + R[4] * y + R[5] * z + t[1];
  world[2] = R[6] * x + R[7] * y + R[8] * z + t[2];
  return best + dir[0] * t[0] + dir[1] * t[1] + dir[2] * t[2];
}

function t3Gjk(cloudA, camA, cloudB, camB, warm, rounds = 8, hints = null) {
  let w = warm && (warm[0] || warm[1] || warm[2]) ? [warm[0], warm[1], warm[2]] : null;
  const a = [0, 0, 0];
  const b = [0, 0, 0];
  if (!w) {
    t3Support(cloudA, camA.R, camA.t, [1, 0, 0], a);
    t3Support(cloudB, camB.R, camB.t, [-1, 0, 0], b);
    w = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
  }
  let margin = -Infinity;
  let best = w;
  const step = (limit) => {
    for (let it = 0; it < limit; it++) {
      const l = Math.hypot(w[0], w[1], w[2]);
      if (l < 1e-12) break;
      const n = [w[0] / l, w[1] / l, w[2] / l];
      const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
      const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
      const value = loB - hiA;
      if (value > margin) {
        margin = value;
        best = n;
      }
      const s = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
      const ds = [w[0] - s[0], w[1] - s[1], w[2] - s[2]];
      const dd = ds[0] * ds[0] + ds[1] * ds[1] + ds[2] * ds[2];
      if (dd < 1e-14) break;
      const tt = Math.max(0, Math.min(1, (w[0] * ds[0] + w[1] * ds[1] + w[2] * ds[2]) / dd));
      w = [w[0] - ds[0] * tt, w[1] - ds[1] * tt, w[2] - ds[2] * tt];
    }
  };
  step(rounds);
  if (margin < 0) step(24);
  if (margin < 0.05 && hints) {
    if (hints.centroids) {
      const [ca, cb] = hints.centroids;
      const wa = [camA.R[0] * ca[0] + camA.R[1] * ca[1] + camA.R[2] * ca[2] + camA.t[0], camA.R[3] * ca[0] + camA.R[4] * ca[1] + camA.R[5] * ca[2] + camA.t[1], camA.R[6] * ca[0] + camA.R[7] * ca[1] + camA.R[8] * ca[2] + camA.t[2]];
      const wb = [camB.R[0] * cb[0] + camB.R[1] * cb[1] + camB.R[2] * cb[2] + camB.t[0], camB.R[3] * cb[0] + camB.R[4] * cb[1] + camB.R[5] * cb[2] + camB.t[1], camB.R[6] * cb[0] + camB.R[7] * cb[1] + camB.R[8] * cb[2] + camB.t[2]];
      const n = t3Unit([wb[0] - wa[0], wb[1] - wa[1], wb[2] - wa[2]]);
      const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
      const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
      if (loB - hiA > margin) {
        margin = loB - hiA;
        best = n;
      }
    }
    for (const [axis, cam] of hints) {
      const R = cam.R;
      const w0 = [R[0] * axis[0] + R[1] * axis[1] + R[2] * axis[2], R[3] * axis[0] + R[4] * axis[1] + R[5] * axis[2], R[6] * axis[0] + R[7] * axis[1] + R[8] * axis[2]];
      for (const sign of [1, -1]) {
        const n = [w0[0] * sign, w0[1] * sign, w0[2] * sign];
        const hiA = t3Support(cloudA, camA.R, camA.t, n, a);
        const loB = -t3Support(cloudB, camB.R, camB.t, [-n[0], -n[1], -n[2]], b);
        if (loB - hiA > margin) {
          margin = loB - hiA;
          best = n;
        }
      }
    }
  }
  const l = Math.hypot(best[0], best[1], best[2]) || 1;
  return { n: [best[0] / l, best[1] / l, best[2] / l], margin };
}

function t3Order(layer, frames, cams, previous, stats, scene) {
  const parts = layer.parts;
  const count = parts.length;
  const local = layer.local;
  const rank = layer._rank ?? (layer._rank = new Int32Array(count));
  if (previous) for (let at = 0; at < count; at++) rank[local.get(previous[at])] = at;
  else for (let at = 0; at < count; at++) rank[local.get(layer.rest[at])] = at;
  let groupEdges = 0;
  if (layer.groupPairs) for (const gp of layer.groupPairs) groupEdges += gp.a.length * gp.b.length;
  const maxEdges = layer.planar.length / 5 + (layer.framed ? layer.framed.length / 6 : 0) + groupEdges + layer.dynamic.length / 2 + (layer.free ? layer.free.length * count : 0) + 1;
  const head = layer._head ?? (layer._head = new Int32Array(count));
  const next = layer._next && layer._next.length >= maxEdges ? layer._next : (layer._next = new Int32Array(maxEdges));
  const to = layer._to && layer._to.length >= maxEdges ? layer._to : (layer._to = new Int32Array(maxEdges));
  const live = layer._live && layer._live.length >= maxEdges ? layer._live : (layer._live = new Uint8Array(maxEdges));
  const indeg = layer._indeg ?? (layer._indeg = new Int32Array(count));
  head.fill(-1);
  indeg.fill(0);
  let edges = 0;
  const addEdge = (back, front) => {
    to[edges] = front;
    next[edges] = head[back];
    live[edges] = 1;
    head[back] = edges;
    indeg[front]++;
    edges++;
  };
  const runs = scene ? scene.runs : null;
  const groupOf = (global) => (runs ? runs[global].group : -1);
  const planar = layer.planar;
  for (let p = 0; p < planar.length; p += 5) {
    const i = planar[p];
    const j = planar[p + 1];
    const fi = frames[i];
    const fj = frames[j];
    if (!t3Overlap(fi.box, fj.box, -0.05)) continue;
    const g = groupOf(i);
    const V = g >= 0 ? cams[g].V : cams.world.V;
    const s = planar[p + 2] * V[0] + planar[p + 3] * V[1] + planar[p + 4] * V[2];
    if (s < 1e-7 && s > -1e-7) continue;
    const li = local.get(i);
    const lj = local.get(j);
    if (s > 0) addEdge(li, lj);
    else addEdge(lj, li);
  }
  const framed = layer.framed;
  if (framed)
    for (let p = 0; p < framed.length; p += 6) {
      const i = framed[p];
      const j = framed[p + 1];
      if (!t3Overlap(frames[i].box, frames[j].box, -0.05)) continue;
      const g = framed[p + 2];
      const V = g >= 0 ? cams[g].V : cams.world.V;
      const s = framed[p + 3] * V[0] + framed[p + 4] * V[1] + framed[p + 5] * V[2];
      if (s < 1e-7 && s > -1e-7) continue;
      const li = local.get(i);
      const lj = local.get(j);
      if (s > 0) addEdge(li, lj);
      else addEdge(lj, li);
    }
  if (layer.groupPairs)
    for (const gp of layer.groupPairs) {
      const union = (list, box) => {
        box[0] = Infinity;
        box[1] = Infinity;
        box[2] = -Infinity;
        box[3] = -Infinity;
        for (let q = 0; q < list.length; q++) {
          const b = frames[list[q]].box;
          if (b[0] < box[0]) box[0] = b[0];
          if (b[1] < box[1]) box[1] = b[1];
          if (b[2] > box[2]) box[2] = b[2];
          if (b[3] > box[3]) box[3] = b[3];
        }
      };
      union(gp.a, gp.boxA);
      union(gp.b, gp.boxB);
      if (!t3Overlap(gp.boxA, gp.boxB, -0.05)) continue;
      const V = gp.frame >= 0 ? cams[gp.frame].V : cams.world.V;
      const s = gp.n[0] * V[0] + gp.n[1] * V[1] + gp.n[2] * V[2];
      if (s < 1e-7 && s > -1e-7) continue;
      for (let x = 0; x < gp.a.length; x++) {
        const i = gp.a[x];
        const bi = frames[i].box;
        if (!t3Overlap(bi, gp.boxB, -0.05)) continue;
        for (let y = 0; y < gp.b.length; y++) {
          const j = gp.b[y];
          if (!t3Overlap(bi, frames[j].box, -0.05)) continue;
          if (s > 0) addEdge(local.get(i), local.get(j));
          else addEdge(local.get(j), local.get(i));
        }
      }
    }
  const dynamic = layer.dynamic;
  let unseparated = 0;
  let solved = 0;
  const rel = layer.rel ?? (layer.rel = new Map());
  const cross = (i, j) => {
    const ri = runs[i];
    const rj = runs[j];
    const ci = ri.group >= 0 ? cams[ri.group] : cams.world;
    const cj = rj.group >= 0 ? cams[rj.group] : cams.world;
    const A = ci.R;
    const B = cj.R;
    const dx = cj.t[0] - ci.t[0];
    const dy = cj.t[1] - ci.t[1];
    const dz = cj.t[2] - ci.t[2];
    const key = i * 65536 + j;
    let memo = rel.get(key);
    if (!memo) {
      memo = { pose: new Float64Array(12), n: [0, 0, 0], margin: -Infinity, fresh: false };
      rel.set(key, memo);
    }
    const pose = memo.pose;
    let same = memo.fresh;
    let at = 0;
    for (let c = 0; c < 3; c++)
      for (let r = 0; r < 3; r++) {
        const v = A[c] * B[r] + A[3 + c] * B[3 + r] + A[6 + c] * B[6 + r];
        if (same && Math.abs(pose[at] - v) > 1e-12) same = false;
        pose[at++] = v;
      }
    for (let c = 0; c < 3; c++) {
      const v = A[c] * dx + A[3 + c] * dy + A[6 + c] * dz;
      if (same && Math.abs(pose[at] - v) > 1e-9) same = false;
      pose[at++] = v;
    }
    let nx;
    let ny;
    let nz;
    if (same) {
      const n = memo.n;
      nx = A[0] * n[0] + A[1] * n[1] + A[2] * n[2];
      ny = A[3] * n[0] + A[4] * n[1] + A[5] * n[2];
      nz = A[6] * n[0] + A[7] * n[1] + A[8] * n[2];
    } else {
      const n = memo.n;
      const warm = memo.fresh ? [A[0] * n[0] + A[1] * n[1] + A[2] * n[2], A[3] * n[0] + A[4] * n[1] + A[5] * n[2], A[6] * n[0] + A[7] * n[1] + A[8] * n[2]] : null;
      const hints = [...ri.axes.map((axis) => [axis, ci]), ...rj.axes.map((axis) => [axis, cj]), [[0, 0, 1], cams.world]];
      hints.centroids = [ri.centroid, rj.centroid];
      const hit = t3Gjk(ri.cloud, ci, rj.cloud, cj, warm, 8, hints);
      solved++;
      nx = hit.n[0];
      ny = hit.n[1];
      nz = hit.n[2];
      n[0] = A[0] * nx + A[3] * ny + A[6] * nz;
      n[1] = A[1] * nx + A[4] * ny + A[7] * nz;
      n[2] = A[2] * nx + A[5] * ny + A[8] * nz;
      memo.margin = hit.margin;
      memo.fresh = true;
    }
    if (memo.margin < -0.08) unseparated++;
    const V = cams.world.V;
    return nx * V[0] + ny * V[1] + nz * V[2];
  };
  for (let p = 0; p < dynamic.length; p += 2) {
    const i = dynamic[p];
    const j = dynamic[p + 1];
    if (!t3Overlap(frames[i].box, frames[j].box, -0.05)) continue;
    const s = cross(i, j);
    if (s < 1e-7 && s > -1e-7) continue;
    const li = local.get(i);
    const lj = local.get(j);
    if (s > 0) addEdge(li, lj);
    else addEdge(lj, li);
  }
  const loose = layer.free;
  if (loose && loose.length) {
    const isLoose = layer.looseAt;
    for (let a = 0; a < loose.length; a++) {
      const i = loose[a];
      const gi = runs[i].group;
      const bi = frames[i].box;
      for (let b = 0; b < count; b++) {
        const j = parts[b];
        if (j === i || runs[j].group === gi) continue;
        if (isLoose[b] && j < i) continue;
        if (!t3Overlap(bi, frames[j].box, -0.05)) continue;
        const s = cross(i, j);
        if (s < 1e-7 && s > -1e-7) continue;
        const li = local.get(i);
        if (s > 0) addEdge(li, b);
        else addEdge(b, li);
      }
    }
  }
  const heap = layer._heap ?? (layer._heap = new Int32Array(count));
  const done = layer._done ?? (layer._done = new Uint8Array(count));
  done.fill(0);
  let size = 0;
  const less = (a, b) => rank[a] < rank[b];
  const push = (v) => {
    let i = size++;
    heap[i] = v;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (!less(heap[i], heap[p])) break;
      const tmp = heap[i];
      heap[i] = heap[p];
      heap[p] = tmp;
      i = p;
    }
  };
  const pop = () => {
    const top = heap[0];
    heap[0] = heap[--size];
    let i = 0;
    for (;;) {
      const l = 2 * i + 1;
      const r = l + 1;
      let s = i;
      if (l < size && less(heap[l], heap[s])) s = l;
      if (r < size && less(heap[r], heap[s])) s = r;
      if (s === i) break;
      const tmp = heap[i];
      heap[i] = heap[s];
      heap[s] = tmp;
      i = s;
    }
    return top;
  };
  for (let v = 0; v < count; v++) if (indeg[v] === 0) push(v);
  const out = new Int32Array(count);
  let placed = 0;
  let forced = 0;
  let refined = 0;
  let tested = false;
  const mark = layer._mark && layer._mark.length >= count ? layer._mark : (layer._mark = new Uint8Array(count));
  const via = layer._via && layer._via.length >= count ? layer._via : (layer._via = new Int32Array(count));
  const iter = layer._iter && layer._iter.length >= count ? layer._iter : (layer._iter = new Int32Array(count));
  const stack = layer._stack && layer._stack.length >= count ? layer._stack : (layer._stack = new Int32Array(count));
  const checked = layer._checked && layer._checked.length >= maxEdges ? layer._checked : (layer._checked = new Uint8Array(maxEdges));
  checked.fill(0, 0, edges);
  const breakCycle = () => {
    mark.fill(0, 0, count);
    for (let root = 0; root < count; root++) {
      if (done[root] || mark[root]) continue;
      let top = 0;
      stack[top++] = root;
      mark[root] = 1;
      iter[root] = head[root];
      while (top) {
        const v = stack[top - 1];
        let e = iter[v];
        while (e >= 0 && (!live[e] || done[to[e]] || mark[to[e]] === 2)) e = next[e];
        if (e < 0) {
          mark[v] = 2;
          top--;
          continue;
        }
        iter[v] = next[e];
        const w = to[e];
        if (mark[w] === 1) {
          let dropped = false;
          let x = v;
          let edge = e;
          for (;;) {
            if (!checked[edge]) {
              checked[edge] = 1;
              const a = x;
              const b = to[edge];
              if (t3Disjoint(frames[parts[a]], frames[parts[b]], 0.1)) {
                live[edge] = 0;
                indeg[b]--;
                refined++;
                dropped = true;
                if (indeg[b] === 0) push(b);
              }
            }
            if (x === w) break;
            edge = via[x];
            let from = -1;
            for (let s = 0; s < top; s++) if (stack[s] === x) from = s > 0 ? stack[s - 1] : -1;
            if (from < 0) break;
            x = from;
          }
          return dropped ? 1 : 0;
        }
        via[w] = e;
        mark[w] = 1;
        iter[w] = head[w];
        stack[top++] = w;
      }
    }
    return -1;
  };
  while (placed < count) {
    if (size === 0) {
      let broken = 0;
      for (let tries = 0; tries < 64 && size === 0; tries++) {
        const result = breakCycle();
        if (result <= 0) break;
        broken++;
      }
      if (size) continue;
      void broken;
      void tested;
      let pick = -1;
      for (let v = 0; v < count; v++) if (!done[v] && (pick < 0 || indeg[v] < indeg[pick] || (indeg[v] === indeg[pick] && rank[v] < rank[pick]))) pick = v;
      forced++;
      indeg[pick] = 0;
      push(pick);
    }
    const v = pop();
    if (done[v]) continue;
    done[v] = 1;
    out[placed++] = parts[v];
    for (let e = head[v]; e >= 0; e = next[e]) {
      if (!live[e]) continue;
      const w = to[e];
      if (done[w]) continue;
      if (--indeg[w] === 0) push(w);
    }
  }
  if (stats) {
    stats.edges = (stats.edges ?? 0) + edges;
    stats.forced = (stats.forced ?? 0) + forced;
    stats.refined = (stats.refined ?? 0) + refined;
    stats.unseparated = (stats.unseparated ?? 0) + unseparated;
    stats.solved = (stats.solved ?? 0) + solved;
  }
  return out;
}

function t3Keep(values) {
  const n = values.length;
  const tails = [];
  const tailAt = [];
  const back = new Int32Array(n).fill(-1);
  for (let i = 0; i < n; i++) {
    let lo = 0;
    let hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (tails[mid] < values[i]) lo = mid + 1;
      else hi = mid;
    }
    tails[lo] = values[i];
    tailAt[lo] = i;
    back[i] = lo > 0 ? tailAt[lo - 1] : -1;
  }
  const keep = new Uint8Array(n);
  let k = tails.length ? tailAt[tails.length - 1] : -1;
  while (k >= 0) {
    keep[k] = 1;
    k = back[k];
  }
  return keep;
}

function t3Cams(scene, values) {
  const poses = t3Poses(scene.groups, values);
  const cams = poses.map((pose) => t3Camera(scene.P, pose));
  cams.world = t3Camera(scene.P, null);
  cams.poses = poses;
  return cams;
}

function t3Mount(stage, data, { onHold = null, onFrame = null, follow = null, painter = "svg" } = {}) {
  void painter;
  const layerEls = new Map([...stage.querySelectorAll("svg[data-live]")].map((svg) => [svg.dataset.live, svg]));
  const first = layerEls.values().next().value;
  const viewWidth = first && first.viewBox && first.viewBox.baseVal ? first.viewBox.baseVal.width : 0;
  const unitOf = () => {
    const width = first ? first.getBoundingClientRect().width : 0;
    return viewWidth && width ? Math.round((width / viewWidth) * 100) / 100 : 1;
  };
  let unit = unitOf();
  let scene = t3Prepare(data, { unit });
  const runs = () => scene.runs;
  const count = data.parts.length;
  const nodes = new Array(count);
  const bound = new Array(count);
  const cache = new Array(count);
  const containers = new Map();
  for (const [name, svg] of layerEls) containers.set(name, svg.querySelector(".iso-parts") ?? svg);
  for (const run of scene.runs) {
    const layer = data.layers[run.layer];
    const container = containers.get(layer.name);
    const node = container ? container.querySelector(`[data-p="${run.index}"]`) : null;
    nodes[run.index] = node;
    const map = {};
    if (node) {
      map.move = node;
      node.querySelectorAll("[data-r]").forEach((el) => (map[el.getAttribute("data-r")] = el));
    }
    bound[run.index] = run.roles.map((role) => map[role.name] ?? null);
    cache[run.index] = run.roles.map(() => ({ d: null, q: null, b: null, f: null, w: null, t: null, o: null, cx: null, cy: null }));
  }
  const frames = scene.runs.map((run) => t3Frame(run));
  const values = Object.fromEntries(data.groups.map((g) => [g.name, data.values ? data.values[g.name] ?? 0 : 0]));
  let lastPoses = null;
  let cams = null;
  const orders = scene.layers.map((layer) => Int32Array.from(layer.rest));
  const changes = [];
  const stats = { ms: 0, writes: 0, moved: 0, forced: 0, refined: 0, unseparated: 0, emitted: 0 };
  let writes = 0;
  const write = (index) => {
    const run = scene.runs[index];
    const f = frames[index];
    const els = bound[index];
    const memo = cache[index];
    for (let r = 0; r < run.roles.length; r++) {
      const el = els[r];
      if (!el) continue;
      const role = run.roles[r];
      const c = memo[r];
      const type = role.type;
      if (type === "path" || type === "face" || type === "slot") {
        const d = f.d[r];
        if (c.d !== d) {
          el.setAttribute("d", d);
          c.d = d;
          writes++;
        }
        if (type === "face") {
          const q = f.q[r];
          if (c.q !== q) {
            const band = Math.min(3, Math.floor(q / T3_Q));
            const mix = (q - T3_Q * band) / T3_Q;
            if (c.b !== band) {
              el.setAttribute("data-b", String(band));
              c.b = band;
              writes++;
            }
            if (c.f !== mix) {
              if (mix) el.style.setProperty("--f", String(mix));
              else el.style.removeProperty("--f");
              c.f = mix;
              writes++;
            }
            c.q = q;
          }
          const w = f.w[r];
          if (c.w !== w) {
            if (w < 0) el.style.removeProperty("stroke-width");
            else el.style.strokeWidth = String(w);
            c.w = w;
            writes++;
          }
        } else if (type === "slot") {
          const o = f.o[r];
          if (c.o !== o) {
            el.setAttribute("opacity", String(Math.round(o * 1000) / 1000));
            c.o = o;
            writes++;
          }
        }
      } else if (type === "xform" || type === "self" || type === "plane") {
        const t = f.t[r];
        if (c.t !== t) {
          if (t) el.setAttribute("transform", t);
          else el.removeAttribute("transform");
          c.t = t;
          writes++;
        }
        if (type === "plane") {
          const o = f.o[r];
          if (c.o !== o) {
            el.setAttribute("opacity", String(o));
            c.o = o;
            writes++;
          }
        }
      } else if (type === "dot") {
        const x = Math.round(f.cx[r] * 100) / 100;
        const y = Math.round(f.cy[r] * 100) / 100;
        const o = f.o[r];
        if (c.cx !== x) {
          el.setAttribute("cx", String(x));
          c.cx = x;
          writes++;
        }
        if (c.cy !== y) {
          el.setAttribute("cy", String(y));
          c.cy = y;
          writes++;
        }
        if (c.o !== o) {
          el.setAttribute("fill-opacity", String(o));
          c.o = o;
          writes++;
        }
      }
    }
  };
  const samePose = (a, b) => {
    if (!a || !b) return false;
    for (let i = 0; i < 9; i++) if (a.R[i] !== b.R[i]) return false;
    return a.t[0] === b.t[0] && a.t[1] === b.t[1] && a.t[2] === b.t[2];
  };
  let lastUnit = unit;
  let moved = 0;
  const render = () => {
    const t0 = performance.now();
    writes = 0;
    moved = 0;
    cams = t3Cams(scene, values);
    t3State.detail = detail;
    const poses = cams.poses;
    const fresh = lastUnit !== unit || !lastPoses;
    let emitted = 0;
    for (const run of scene.runs) {
      const g = run.group;
      const changed = fresh || (g >= 0 ? !samePose(poses[g], lastPoses[g]) : false);
      if (!changed) continue;
      t3Emit(run, g >= 0 ? cams[g] : cams.world, frames[run.index]);
      write(run.index);
      emitted++;
    }
    lastPoses = poses;
    lastUnit = unit;
    const orderStats = { forced: 0, refined: 0, unseparated: 0, edges: 0 };
    scene.layers.forEach((layer, index) => {
      const previous = orders[index];
      const order = t3Order(layer, frames, cams, previous, orderStats, scene);
      let same = true;
      for (let j = 0; j < order.length; j++)
        if (order[j] !== previous[j]) {
          same = false;
          break;
        }
      if (same) return;
      const container = containers.get(layer.name);
      const position = new Map();
      previous.forEach((global, at) => position.set(global, at));
      const keep = t3Keep(Array.from(order, (global) => position.get(global)));
      let after = null;
      const shifted = [];
      for (let j = order.length - 1; j >= 0; j--) {
        const node = nodes[order[j]];
        if (!keep[j] && node && container) {
          container.insertBefore(node, after);
          moved++;
          shifted.push(scene.runs[order[j]].name);
        }
        if (node) after = node;
      }
      orders[index] = order;
      if (shifted.length) changes.push({ layer: layer.name, moved: shifted });
    });
    stats.ms = performance.now() - t0;
    stats.writes = writes;
    stats.moved = moved;
    stats.forced = orderStats.forced;
    stats.refined = orderStats.refined;
    stats.unseparated = orderStats.unseparated;
    stats.emitted = emitted;
    t3State.detail = 1;
    if (onFrame) onFrame(controller);
    return { ...stats };
  };
  let detail = 1;
  const set = (next, options) => {
    if (options && options.detail !== undefined) {
      const value = Math.max(0, Math.min(1, options.detail));
      if (value !== detail) {
        detail = value;
        lastPoses = null;
      }
    }
    if (typeof next === "number") {
      const g = data.groups.find((group) => group.kind === "turn");
      if (g) values[g.name] = next;
    } else if (next) for (const [name, value] of Object.entries(next)) values[name] = value;
    if (follow) Object.assign(values, follow({ ...values }));
    return render();
  };
  let unwrap = new Map();
  const angleAt = (clientX, clientY, z = 0, groupName) => {
    const svg = first;
    const box = svg.getBoundingClientRect();
    const vb = svg.viewBox.baseVal;
    const x = vb.x + ((clientX - box.left) / box.width) * vb.width;
    const y = vb.y + ((clientY - box.top) / box.height) * vb.height;
    const c = t3Camera(scene.P, null);
    const p = (x - c.ox) / c.k;
    const q = (y - c.oy + z * c.ce * c.k) / (c.se * c.k);
    const wx = p * c.sa + q * c.ca;
    const wy = -p * c.ca + q * c.sa;
    const group = data.groups.find((g) => g.name === groupName) ?? data.groups.find((g) => g.kind === "turn");
    const pivot = group ? group.origin : [0, 0, 0];
    const raw = (Math.atan2(wy - pivot[1], wx - pivot[0]) * 180) / Math.PI;
    const key = group ? group.name : "";
    const last = unwrap.get(key);
    let angle = raw;
    if (last !== undefined) angle = last + ((((raw - last) % 360) + 540) % 360) - 180;
    unwrap.set(key, angle);
    return angle;
  };
  const observer = typeof ResizeObserver === "function" && first
    ? new ResizeObserver(() => {
        const next = unitOf();
        if (Math.abs(next - unit) < 0.05) return;
        unit = next;
        scene = t3Prepare(data, { unit });
        render();
      })
    : null;
  if (observer) observer.observe(first);
  if (follow) Object.assign(values, follow({ ...values }));
  const controller = {
    set,
    render,
    angleAt,
    get values() {
      return { ...values };
    },
    cover: (names, options) => t3Cover(controller, names, options),
    order: (name) => {
      const index = name === undefined ? 0 : scene.layers.findIndex((layer) => layer.name === name);
      return index < 0 ? [] : Array.from(orders[index], (global) => scene.runs[global].name);
    },
    orderIndex: (name) => {
      const index = name === undefined ? 0 : scene.layers.findIndex((layer) => layer.name === name);
      return index < 0 ? null : orders[index];
    },
    events: () => changes.splice(0, changes.length),
    stats: () => ({ ...stats }),
    frames: () => frames,
    cams: () => cams,
    scene: () => scene,
    pose: (name) => t3PoseOf(scene, cams, name),
    detach: (name) => {
      const index = scene.groups.findIndex((g) => g.name === name);
      const pose = t3PoseOf(scene, cams, name);
      const o = scene.groups[index].origin;
      return { world: true, R: pose.R, t: [pose.t[0] - o[0], pose.t[1] - o[1], pose.t[2] - o[2]] };
    },
    anchor: (name, point, direction) => t3Anchor(scene, cams, name, point, direction),
    destroy: () => {
      if (observer) observer.disconnect();
      if (typeof window !== "undefined" && window.__isoTurn && window.__isoTurn.controller === controller) delete window.__isoTurn;
    },
  };
  const hold = () => {
    if (onHold) onHold();
  };
  if (typeof window !== "undefined") {
    window.__isoTurn = {
      controller,
      set(next) {
        hold();
        return set(next);
      },
      get() {
        hold();
        return { ...values };
      },
      order(name) {
        hold();
        return controller.order(name);
      },
      events() {
        hold();
        return controller.events();
      },
      stats() {
        hold();
        return controller.stats();
      },
      layers() {
        hold();
        return scene.layers.map((layer) => layer.name);
      },
    };
  }
  render();
  return controller;
}

function t3Enclose(points, edges) {
  const hull = t3Hull(points);
  const n = hull.length;
  if (n < 3) return [];
  let area = 0;
  for (let i = 0; i < n; i++) area += hull[i][0] * hull[(i + 1) % n][1] - hull[(i + 1) % n][0] * hull[i][1];
  const sign = area >= 0 ? 1 : -1;
  let lines = [];
  for (let i = 0; i < n; i++) {
    const p = hull[i];
    const q = hull[(i + 1) % n];
    const dx = q[0] - p[0];
    const dy = q[1] - p[1];
    const l = Math.hypot(dx, dy);
    if (l < 1e-9) continue;
    const nx = (sign * dy) / l;
    const ny = (-sign * dx) / l;
    lines.push([nx, ny, -(nx * p[0] + ny * p[1])]);
  }
  const meet = (A, B) => {
    const det = A[0] * B[1] - A[1] * B[0];
    if (Math.abs(det) < 1e-12) return null;
    return [(-A[2] * B[1] + B[2] * A[1]) / det, (-A[0] * B[2] + B[0] * A[2]) / det];
  };
  while (lines.length > edges) {
    let best = -1;
    let cost = Infinity;
    const m = lines.length;
    for (let i = 0; i < m; i++) {
      const prev = lines[(i - 1 + m) % m];
      const self = lines[i];
      const next = lines[(i + 1) % m];
      const turn = prev[0] * next[1] - prev[1] * next[0];
      if (sign * turn <= 1e-9) continue;
      const apex = meet(prev, next);
      const a = meet(prev, self);
      const b = meet(self, next);
      if (!apex || !a || !b) continue;
      const added = Math.abs((a[0] - apex[0]) * (b[1] - apex[1]) - (a[1] - apex[1]) * (b[0] - apex[0])) / 2;
      if (added < cost) {
        cost = added;
        best = i;
      }
    }
    if (best < 0) break;
    lines.splice(best, 1);
  }
  return lines;
}

function t3Cover(controller, names, { polygons = 6, edges = 10, pad = 0.3, layers = null } = {}) {
  const uCoverEdge = new Float32Array(polygons * edges * 3);
  const uCoverCount = new Float32Array(polygons);
  const scene = controller.scene();
  const frames = controller.frames();
  const sets = Array.isArray(names) ? (names.length && Array.isArray(names[0]) ? names : [names]) : [[names]];
  const candidates = [];
  const taken = new Set();
  for (const list of sets) {
    const targets = scene.runs.filter((run) => list.includes(run.name));
    if (!targets.length) continue;
    const box = [Infinity, Infinity, -Infinity, -Infinity];
    for (const run of targets) {
      const b = frames[run.index].box;
      box[0] = Math.min(box[0], b[0]);
      box[1] = Math.min(box[1], b[1]);
      box[2] = Math.max(box[2], b[2]);
      box[3] = Math.max(box[3], b[3]);
    }
    const home = targets[0].layer;
    scene.layers.forEach((layer, index) => {
      const order = controller.orderIndex(layer.name);
      if (!order) return;
      let from = 0;
      if (index === home) {
        for (let at = 0; at < order.length; at++) if (targets.some((run) => run.index === order[at])) from = at + 1;
      } else if (!(layers && layers.includes(layer.name)) || index < home) return;
      for (let at = from; at < order.length; at++) {
        const run = scene.runs[order[at]];
        if (list.includes(run.name) || taken.has(run.index)) continue;
        const f = frames[run.index];
        const b = f.box;
        if (!(b[0] < box[2] + pad && box[0] < b[2] + pad && b[1] < box[3] + pad && box[1] < b[3] + pad)) continue;
        if (!targets.some((target) => !t3Disjoint(frames[target.index], f, -pad))) continue;
        taken.add(run.index);
        const share = (Math.min(b[2], box[2]) - Math.max(b[0], box[0])) * (Math.min(b[3], box[3]) - Math.max(b[1], box[1]));
        candidates.push({ run, f, share });
      }
    });
  }
  candidates.sort((a, b) => b.share - a.share);
  const parts = [];
  let slot = 0;
  for (const { run, f } of candidates) {
    if (slot >= polygons) break;
    const points = [];
    if (f.quadN) for (const [buf, count] of f.quadList) for (let i = 0; i < count; i++) points.push([buf[2 * i], buf[2 * i + 1]]);
    else for (let i = 0; i < f.hullN; i++) points.push([f.hull[2 * i], f.hull[2 * i + 1]]);
    const lines = t3Enclose(points, edges);
    if (!lines.length) continue;
    lines.forEach((line, e) => {
      const at = (slot * edges + e) * 3;
      uCoverEdge[at] = line[0];
      uCoverEdge[at + 1] = line[1];
      uCoverEdge[at + 2] = line[2] - pad;
    });
    uCoverCount[slot] = lines.length;
    parts.push(run.name);
    slot++;
  }
  return { uCoverEdge, uCoverCount, dropped: Math.max(0, candidates.length - slot), parts };
}

function t3PoseOf(scene, cams, name) {
  const index = scene.groups.findIndex((g) => g.name === name);
  const cam = index >= 0 && cams ? cams[index] : null;
  return { R: cam ? Array.from(cam.R) : Array.from(T3_EYE), t: cam ? [cam.t[0], cam.t[1], cam.t[2]] : [0, 0, 0] };
}

function t3Anchor(scene, cams, name, point, direction) {
  const index = name === null || name === undefined ? -1 : scene.groups.findIndex((g) => g.name === name);
  const cam = index >= 0 ? cams[index] : cams.world;
  const o = index >= 0 ? scene.groups[index].origin : [0, 0, 0];
  const R = cam.R;
  const q = [point[0] - o[0], point[1] - o[1], point[2] - o[2]];
  const world = [R[0] * q[0] + R[1] * q[1] + R[2] * q[2] + cam.t[0], R[3] * q[0] + R[4] * q[1] + R[5] * q[2] + cam.t[1], R[6] * q[0] + R[7] * q[1] + R[8] * q[2] + cam.t[2]];
  const w = cams.world;
  const screen = [w.ox + w.m[0] * world[0] + w.m[1] * world[1] + w.m[2] * world[2], w.oy + w.m[3] * world[0] + w.m[4] * world[1] + w.m[5] * world[2]];
  const out = { world, screen };
  if (direction) out.direction = [R[0] * direction[0] + R[1] * direction[1] + R[2] * direction[2], R[3] * direction[0] + R[4] * direction[1] + R[5] * direction[2], R[6] * direction[0] + R[7] * direction[1] + R[8] * direction[2]];
  return out;
}

function t3FloorOf(scene, name, R = T3_EYE) {
  const index = scene.groups.findIndex((g) => g.name === name);
  let low = Infinity;
  for (const run of scene.runs) {
    if (run.group !== index) continue;
    const c = run.cloud;
    for (let i = 0; i < c.length; i += 3) {
      const z = R[6] * c[i] + R[7] * c[i + 1] + R[8] * c[i + 2];
      if (z < low) low = z;
    }
  }
  return low;
}

function t3Compose(A, B) {
  return Array.from(t3Mul(A, B));
}

function t3PoseUniforms(controller, group, prefix = "uGroup") {
  const scene = controller.scene();
  const index = scene.groups.findIndex((g) => g.name === group);
  const cams = controller.cams();
  const cam = index >= 0 && cams ? cams[index] : null;
  const R = cam ? cam.R : T3_EYE;
  const t = cam ? cam.t : [0, 0, 0];
  const o = index >= 0 ? scene.groups[index].origin : [0, 0, 0];
  const T = [t[0] - (R[0] * o[0] + R[1] * o[1] + R[2] * o[2]), t[1] - (R[3] * o[0] + R[4] * o[1] + R[5] * o[2]), t[2] - (R[6] * o[0] + R[7] * o[1] + R[8] * o[2])];
  return { [`${prefix}X`]: [R[0], R[3], R[6]], [`${prefix}Y`]: [R[1], R[4], R[7]], [`${prefix}Z`]: [R[2], R[5], R[8]], [`${prefix}T`]: T };
}

const T3_MAT_KEYS = ["paper", "face", "top", "shade-0", "shade-1", "shade-2", "shade-3", "lit-top", "lit-shade", "hi", "mid", "lo", "faint", "dot", "lit"];
const T3_MAT_TUBE = ["body", "shine", "shade", "edge"];

const T3_MATERIALS = {
  gold: {
    light: ["#d3bb88", "#e4d2a8", "#f1e5c6", "#a1844f", "#bc9f69", "#d3bb88", "#e4d2a8", "#f8efd8", "#dfcb9c", "#4b3920", "#86693f", "#a98c5b", "#c1a676", "#4a381f", "#2a1d0c", "#cdb07a", "#f3e8cc", "#8e7044", "#4b3920"],
    dark: ["#3f3521", "#4b3f28", "#5a4c31", "#2b2416", "#352c1b", "#3f3521", "#4b3f28", "#6a5a3b", "#4f4329", "#e6d2a3", "#8a7550", "#5e4e33", "#463a26", "#e6d2a3", "#fff3d6", "#4a3e27", "#8c7853", "#251f13", "#e6d2a3"],
  },
  chrome: {
    light: ["#8e959c", "#c3c8cd", "#f7f8f9", "#a2a9b0", "#6f767d", "#454b52", "#cacfd4", "#ffffff", "#bcc2c8", "#202428", "#585e65", "#878d94", "#a9afb5", "#2a2e33", "#000000", "#8b9299", "#fbfcfd", "#3c4248", "#202428"],
    dark: ["#2a2e32", "#4a5056", "#6e757c", "#2e3236", "#24272b", "#1a1d20", "#4a5056", "#848b92", "#3a3f44", "#dfe3e7", "#7c838a", "#4d5359", "#363b40", "#e4e7ea", "#ffffff", "#34393e", "#9aa1a8", "#16191b", "#dfe3e7"],
  },
  steel: {
    light: ["#b8bdc1", "#cdd1d4", "#e5e8ea", "#8f959b", "#a4aaaf", "#b8bdc1", "#cdd1d4", "#f1f3f4", "#c6cacd", "#2b2f33", "#6b7177", "#959ba1", "#aeb3b8", "#33373b", "#000000", "#b3b8bd", "#eef0f2", "#7d8389", "#2b2f33"],
    dark: ["#2c3034", "#34393d", "#3e4348", "#1f2225", "#25292c", "#2c3034", "#34393d", "#4a5056", "#33383c", "#d0d4d8", "#727980", "#4a5056", "#33383d", "#d8dce0", "#ffffff", "#30353a", "#5c6369", "#1a1d20", "#d0d4d8"],
  },
  gunmetal: {
    light: ["#444a51", "#50575e", "#5f666e", "#2f3439", "#393f45", "#444a51", "#50575e", "#6d757d", "#4b5258", "#0f1113", "#1d2125", "#272b30", "#30353a", "#b0b5ba", "#000000", "#3d4349", "#6e757c", "#24282c", "#0f1113"],
    dark: ["#1d2125", "#22272b", "#2a2f34", "#15181b", "#191c20", "#1d2125", "#22272b", "#323840", "#20252a", "#a9b0b7", "#5c636a", "#3a4046", "#2a2f34", "#b8bec4", "#ffffff", "#1f2327", "#3a4046", "#111316", "#a9b0b7"],
  },
  rubber: {
    light: ["#2a2b2e", "#34363a", "#46484d", "#1b1c1e", "#222326", "#2a2b2e", "#34363a", "#55575c", "#2f3134", "#070708", "#111214", "#18191b", "#1f2023", "#6a6c70", "#000000", "#27282b", "#5b5d62", "#121314", "#050506"],
    dark: ["#121213", "#161618", "#1c1c1f", "#0c0c0d", "#0f0f10", "#121213", "#161618", "#232327", "#141416", "#8c8e92", "#4a4c50", "#2c2d30", "#1f2023", "#9a9ca0", "#ffffff", "#131315", "#2b2c30", "#0a0a0b", "#8c8e92"],
  },
  brass: {
    light: ["#bd9646", "#d2ad5b", "#e6c87e", "#8a6a2c", "#a47f38", "#bd9646", "#d2ad5b", "#f0d796", "#c8a252", "#3f2e0e", "#765a24", "#9a7a3c", "#b08c46", "#3a2a0c", "#1f1606", "#b8913f", "#efd795", "#7d5f26", "#3f2e0e"],
    dark: ["#3d3017", "#49391c", "#574523", "#2a210f", "#332813", "#3d3017", "#49391c", "#66522b", "#45371b", "#e9cf8a", "#8e7438", "#5e4c25", "#44371c", "#e9cf8a", "#fff0c8", "#433419", "#8a6f36", "#21190b", "#e9cf8a"],
  },
  copper: {
    light: ["#b3633e", "#c9784f", "#df9670", "#7c4128", "#9a5132", "#b3633e", "#c9784f", "#eaa985", "#be6d46", "#3a1a0c", "#6e3820", "#925037", "#a65d3f", "#3a1a0c", "#1c0b04", "#ad5f3b", "#f0b896", "#6e3720", "#3a1a0c"],
    dark: ["#3d1f13", "#492617", "#582e1c", "#2a160d", "#331a10", "#3d1f13", "#492617", "#673722", "#432215", "#eaa985", "#8f5236", "#5f3624", "#45271a", "#eaa985", "#ffe0cc", "#42221a", "#8a4c31", "#1f0f08", "#eaa985"],
  },
};

function t3MaterialCss() {
  const rules = [];
  const block = (values) => T3_MAT_KEYS.map((key, i) => `--anatomy-${key}:${values[i]}`).concat(T3_MAT_TUBE.map((key, i) => `--mat-${key}:${values[T3_MAT_KEYS.length + i]}`)).join(";");
  for (const [name, { light, dark }] of Object.entries(T3_MATERIALS)) {
    rules.push(`[data-mat="${name}"]{${block(dark)}}`);
    rules.push(`.iso[data-theme="light"] [data-mat="${name}"]{${block(light)}}`);
    rules.push(`.iso[data-theme="dark"] [data-mat="${name}"]{${block(dark)}}`);
  }
  rules.push("[data-mat] .tb-body{fill:var(--mat-body)}");
  rules.push("[data-mat] .tb-shine{fill:var(--mat-shine)}");
  rules.push("[data-mat] .tb-shade{fill:var(--mat-shade)}");
  rules.push("[data-mat] .tb-edge{stroke:var(--mat-edge)}");
  return rules.join("\n");
}

function t3Css() {
  const rules = [];
  const ramp = "--tn-0:var(--anatomy-shade-0);--tn-1:var(--anatomy-shade-1);--tn-2:var(--anatomy-shade-2);--tn-3:var(--anatomy-shade-3);--tn-4:var(--anatomy-top)";
  rules.push(`.iso-live{${ramp}}`);
  rules.push(`.iso-live [data-mat]{${ramp}}`);
  rules.push(".iso-live [data-lit]{--tn-0:var(--anatomy-lit-shade);--tn-1:var(--anatomy-lit-shade);--tn-2:var(--anatomy-lit-shade);--tn-3:var(--anatomy-lit-shade);--tn-4:var(--anatomy-lit-top)}");
  const colour = "color-mix(in srgb,var(--ta) calc(100% - var(--f,0) * 100%),var(--tb))";
  rules.push(`.iso-tn{stroke-width:.4;stroke-linejoin:round;--ta:var(--tn-0);--tb:var(--tn-1);fill:${colour};stroke:${colour}}`);
  for (let i = 1; i < 4; i++) rules.push(`.iso-tn[data-b="${i}"]{--ta:var(--tn-${i});--tb:var(--tn-${i + 1})}`);
  rules.push(t3MaterialCss());
  for (const hue of ["red", "green", "blue"]) rules.push(`.tb[data-hue="${hue}"]{--tb-ink:var(--anatomy-${hue})}`);
  rules.push(".tb[data-hue] .tb-body{fill:color-mix(in srgb,var(--tb-ink) 54%,var(--anatomy-shade-2))}");
  rules.push(".tb[data-hue] .tb-shine{fill:color-mix(in srgb,var(--tb-ink) 16%,var(--anatomy-top))}");
  rules.push(".tb[data-hue] .tb-shade{fill:color-mix(in srgb,var(--tb-ink) 30%,var(--anatomy-hi))}");
  rules.push(".tb[data-hue] .tb-edge{stroke:color-mix(in srgb,var(--tb-ink) 38%,var(--anatomy-hi))}");
  rules.push(".tb[data-hue][data-mat] .tb-body{fill:color-mix(in srgb,var(--tb-ink) 62%,var(--mat-body))}");
  rules.push(".tb[data-hue][data-mat] .tb-shine{fill:color-mix(in srgb,var(--tb-ink) 22%,var(--mat-shine))}");
  rules.push(".tb[data-hue][data-mat] .tb-shade{fill:color-mix(in srgb,var(--tb-ink) 46%,var(--mat-shade))}");
  rules.push(".tb[data-hue][data-mat] .tb-edge{stroke:color-mix(in srgb,var(--tb-ink) 30%,var(--mat-edge))}");
  rules.push(".iso-turn{position:relative}");
  rules.push(".iso-turn>.iso-layer{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}");
  return rules.join("\n");
}

export const TURN = { materials: Object.keys(T3_MATERIALS), materialCss: t3MaterialCss, rotation: (axis, degrees) => Array.from(t3Rot(axis, degrees)), quat: (q) => Array.from(t3Quat(q)), compose: t3Compose, floorOf: t3FloorOf, anchor: t3Anchor, camera: t3Camera, poses: t3PosesMap, prepare: t3Prepare, emit: t3Emit, order: t3Order, mount: t3Mount, cover: t3Cover, poseUniforms: t3PoseUniforms, css: t3Css, frame: t3Frame, cams: t3Cams, gjk: t3Gjk, hintsOf: (ri, ci, rj, cj, world) => Object.assign([...ri.axes.map((axis) => [axis, ci]), ...rj.axes.map((axis) => [axis, cj]), [[0, 0, 1], world]], { centroids: [ri.centroid, rj.centroid] }), disjoint: t3Disjoint, levels: T3_LEVELS, q: T3_Q, exact: (on) => { t3State.exact = Boolean(on); } };
export const TURN_CSS = t3Css();
```

## references

```

```

## references/3d.md

# 3D: parts that turn in the drawing

The optional 3D mode ("turning groups") lets some parts of a figure move as rigid bodies: a dial or turntable that spins, fingers that flex, a piece that breaks off and tumbles. Those parts are re-projected and re-shaded every frame by the kit's own rules and painted in an order solved from separating planes. Everything else stays ordinary static kit art. A figure that doesn't opt in is untouched.

**Status: beta.** The engine is proved on `examples/turning-dial` and one test figure (a desk fan built from this document alone), not yet on a production figure. Turn one subject at a time, keep clutter static, and measure your figure against section 15 before you add parts.

## Contents
1. When a figure earns 3D
2. The model
3. A minimal page
4. The authoring API
5. Part kinds and the rules that keep them true
6. Materials
7. Free groups: parts that break off
8. Hinge chains: hands and fingers
9. Painter's order and why it is correct
10. Shading and fades
11. Static layers and a messy workshop around a turntable
12. Shaders that follow the turn and the groups
13. The orbit audit
14. Browser checks
15. Performance
16. Known limits

## 1. When a figure earns 3D

Only when the motion is the explanation: a dial whose numbers are read as it turns, a turntable that shows an object from every side, a wheel pack, a hand that grips or breaks. Never as decoration. A figure that only needs a part to slide or a needle to swing stays 2D: `translateAlong` and a re-drawn needle are cheaper and exact.

## 2. The model

- **Groups.** A group is a rigid frame with a pose. Kinds: `turn` (about a vertical axis through `pivot`, any angle), `slide` (along a direction, `0…travel`), `hinge` (about any axis through a point, within `range`, in degrees) and `free` (any rotation plus a translation, set by the figure every frame). Groups nest: a pose is relative to the parent.
- **Rest.** Every constructor takes **world coordinates at rest** (every group value 0, every free group `null`). The engine subtracts each group's origin, so the recorder (`A.recorder`), the kit and the engine share one world.
- **Live layers.** A live layer is one `<svg class="iso-svg iso-live" data-live="name">` in the stage. Live and static layers stack in the order of `T.stack([...])`: for example `back svg → [dial live] → back canvas → [hand live] → front canvas → front svg`.
- **The identity.** Turning a group by θ about a vertical axis is exactly the kit drawing at azimuth `a − θ`, because the kit's light is camera-locked. So every frame of a turn group is the kit's own picture at P′(θ), shifted; the kit builders at P′(θ) are the fidelity reference, and every kit screen-space audit runs unchanged at P′(θ). Hinges and free groups compose matrices instead (correct, slightly slower).

## 3. A minimal page

`examples/turning-dial/` is the reference scene: a dial with a knurl, ticks and numerals, a second turn group (an index wheel under a window), a hinge (a pawl riding the knurl), a hand of about 80 parts with materials, a three-hinge finger that lifts, a hex pod that breaks off as a free group, and a WebGL LED, light pool and sparks. `examples/turning-dial/stress.mjs` is the performance scene (a dense hand on a turntable in a cluttered workshop); it is not audit-clean, so take patterns from `build.mjs`.

`build.mjs`, reduced to its skeleton:
```js
import * as k from "../../kit/iso-kit.mjs";
import * as A from "../../kit/audit.mjs";
import { turning } from "../../kit/turn-build.mjs";
import * as TA from "../../kit/turn-audit.mjs";
import { turnScript, glScript } from "../../scripts/inline-kit.mjs";

export const P = k.fitProjection(points, W, H, { pad: 22, azimuth: 40, elevation: 31 });
export const R = A.recorder(P);
export const T = turning(P, { recorder: R });
const dial = T.group("dial", { turn: { pivot: [0, 0] } });
const lift = T.group("f2.mcp", { parent: dial, hinge: { point: C1, axis: w, range: [-22, 0] } });
const hand = T.layer("hand");
T.stack(["back", "hand", "front"]);
export const follow = (values) => ({ ...values, "f2.mcp": -22 * (values.lift ?? 0) });
export const poses = () => Array.from({ length: 720 }, (_, i) => follow({ dial: i * 0.5, lift: (i % 90) / 90 }));

T.round({ name: "dial.body", group: dial, layer: hand, F: VERT(0, 0), s0: 10, s1: 14.4, r: 50, material: "steel", details: { ribs: { s0: 10.9, s1: 13.7, count: 150, seams: true, tone: "lo" } } });
T.prism({ name: "hand.palm", group: dial, layer: hand, plan: PALM, z: 19.4, h: 8, bevel: 0.9, material: "gold" });
T.tube({ name: "cable.red", group: dial, layer: hand, route, r: 0.7, hue: "red", material: "rubber", gaps });
// … static kit art: R.put({ name, layer: "back", svg, shapes }) as usual …

const out = T.build({ statics: R.items, poses, verify: argv.includes("--verify") });
A.settle(R, P);
if (argv.includes("--audit")) {
  A.auditOrExit(R, P);
  TA.orbitOrExit(T, R, P, { sweep: { at: (x) => follow({ dial: x }), from: 0, to: 360, frame: 0.3 }, clips: [{ name: "tap", at: (t) => follow({ dial: 40 + 12 * t, lift: tapAt(t) }), from: 0, to: 4, frame: 1 / 60 }] });
}
const stage = `<div class="iso-stage iso-turn" tabindex="0" role="slider" …>${backSvg}${out.layerSvg("hand", { width: W, height: H })}${frontSvg}</div>`;
const page = k.pageHtml({ title, theme, body: k.plateHtml({ …, body: `<style>${out.css}</style>${stage}` }), script: turnScript() + LIVE });
```
`live.js`:
```js
const controller = TURN.mount(stage, DATA.turn, { follow, onHold: () => (touring = false), onFrame: () => paint() });
function tick(now) {
  …
  controller.set({ dial: angle, lift });
  frame = visible && moving ? requestAnimationFrame(tick) : 0;
}
```
The figure owns the loop (`motion.md`); the controller never runs its own `requestAnimationFrame`. `out.css` is `TURN_CSS` (the face band rules, materials, hue tubes and the `.iso-turn` stacking): include it once.

**In React or Next.js** (`references/react.md`): copy `turn.mjs`, `turn-build.mjs` and `turn-audit.mjs` beside the kit with the imports rewritten (`./iso-kit.mjs` → `./kit`, and so on), and `TURN_CSS` as a `turn.css` imported once. The geometry module (server) runs `turning(…)` and `T.build(…)` and returns the stage markup (`out.layerSvg(name, { width, height })` for each live layer, in stack order between the static SVGs and canvases) plus `out.data` in the figure's data. The client mount calls `TURN.mount(stageElement, data.turn, { follow, onHold, onFrame })` and drives `controller.set(...)` from its own loop; return `controller.destroy` from the effect's cleanup. A verify build can put `out.verify` in the data and have the mount assign `window.__isoVerify` for `turn-check --order`.

## 4. The authoring API

### Scene
| Call | What it does |
|---|---|
| `turning(P, { recorder, scale = 1 })` | The builder `T`. Pass the kit recorder so every live shape is recorded for the kit audit too. |
| `T.group(name, { parent?, turn: { pivot: [x, y] } })` | Rotation about the vertical through `pivot`, value in degrees. |
| `T.group(name, { parent?, slide: { direction, travel } })` | Translation, value `0…travel` world units. |
| `T.group(name, { parent?, hinge: { point, axis, range: [lo, hi] } })` | Rotation about `axis` through `point`, value in degrees within `range`. |
| `T.group(name, { parent?, free: { origin } })` | A free rigid body (section 7). Value `{ R: [9] \| q: [x, y, z, w] \| axis, angle, t: [3], world? }` or `null` for rest. |
| `T.layer(name)`, `T.stack([…])` | Live layers and the full stack of live and static layer names, back to front. |
| `T.build({ statics, poses, verify = false, strict = true })` | Solves every pair, proves every static item's layer, packs the page data. `poses` is the figure's reachable states (a list or a function returning one): every motion clip, sampled. Throws a readable error on any pair with no separating plane, any static that can't be proved, any non-convex part. `strict: false` keeps interlocked pairs (so a planted fault reaches the audit). |

`T.build` returns `out = { svg, data, css, report, layerSvg(name, { width, height }), verify }`. `out.data` goes to the page (`DATA.turn`). `out.report` has `parts`, `layers` (per layer: `planar`, `framed`, `dynamic`, `skip` counts), `tight`, `margins`, `proofs`, `seconds`.

### Constructors
Common options on every constructor: `{ name, group, layer, tone = "hi", crease = "faint", lit = false, material, owner, details = {} }`.

| Constructor | Part |
|---|---|
| `T.prism({ plan, z, h, steps, bevel })` | An upright kit slab (the kit `extrude` rule). |
| `T.prism({ F, polygon, s0, s1, round = 0, steps = 4, bevel = 0 })` | A convex polygon in frame `F = { o, a, u, v }` extruded along `F.a` from `s0` to `s1`, any axis (the `prismOf` rule). |
| `T.round({ F, s0, s1, r \| [r0, r1], ends = ["flat", "flat"], fixed })` | Cylinder or cone frustum; each end `"flat"`, `"dome"` (radius must equal the side's: a capsule) or `"open"`. One dome and one flat end is a half-capsule, and any length works, even one shorter than the radius (a nose cone on a motor can). A vertical round in an upright group becomes a `fixed` picture automatically (`fixed: false` stops it). |
| `T.lathe({ F, profile, slope, radius })` | A general convex smooth body (r(s) concave). Costly; prefer rounds. |
| `T.ball({ c, r, flats: [{ n, d }] })` | A sphere; each flat keeps n·(p − c) ≤ d and draws its rim as a cap, so `n` is the flat's outward normal. Without flats it is a `fixed` picture, and it takes no `details`. It is the costliest kind to draw (section 15). |
| `T.tube({ route, r, chunk = 6, turn = 20, gaps, breaks, caps, rings, hue, material, bundle, owner, touch })` | The kit `tubePieces` semantics, chunked; `hue` is `"red" \| "green" \| "blue"`; records `R.route`. The route runs from mount surface to mount surface. The port, gland or clamp at each end is a separate part with `owner` set to the tube's name, and its span along the route goes in `gaps`. A route that starts at a port's outer face fails `terminals` in the kit audit (below). |
| `T.fixed({ svg, anchor, shapes, hull, F })` | Any kit markup whose picture never changes under the group's motion, moved by `translate`. |
| `T.plane({ part, o, u, v, svg, fade = [0.04, 0.3], normal })` | Line or text markup in a plane of the part (ticks, numerals, label plates), placed by one matrix, faded by facing. |
| `T.billboard({ part, at, normal, svg, fade })` | Screen-aligned markup at a projected point (an SVG fallback for a shader glow), faded by facing. `svg` is in viewBox px centred on the projected point, not in world units: multiply world sizes by `G.scaleOf(P)` (`<circle r="${(1.1 * G.scaleOf(P)).toFixed(2)}"/>` fills a lens of radius 1.1). |
| `T.split(part, { n, d })` | Two parts cut by the plane (prism: any plane through it along or across the axis; round: across). The cut edges are not stroked. Use it to break a genuine cycle. |

### Details (in the part's frame: `s` along the axis, `x` and `y` in `u` and `v`)
Each constructor has its own frame and takes its own detail kinds. Anything else is ignored without a warning.

| Constructor | Frame `[s, x, y]` | Takes |
|---|---|---|
| `T.round`, `T.lathe` | `F`: `s` along `F.a`, `x` along `F.u`, `y` along `F.v` | `seams`, `ribs`, `bolts`, `dots`, `rings`, `rules` |
| `T.prism({ F, polygon })` | `F` | `dots`, `seams`, `bevel` |
| `T.prism({ plan, z, h })` | The world: `s` is z, `x` and `y` are world x and y | `dots`, `seams` (z values), `bevel` |
| `T.ball` with flats | Origin `c`, `a` = the first flat's `n`, `u` and `v` from `G.frameAlong(c, n)`. The flat is at `s = d` and the far pole at `s = −r` | `bolts`, `dots`, `rings`, `rules` |
| `T.ball` without flats | None: a fixed picture | Nothing |
| `T.fixed({ F })` | `F` (no `F`: no details) | `bolts`, `dots`, `rings`, `rules` |

A centre dot on a domed spinner `T.ball({ c, r: 4.5, flats: [{ n: [-1, 0, 0], d: 0 }] })` (the flat faces −x, the dome +x) sits at `at: [-4.5, 0, 0], normal: [1, 0, 0]`: `s = −r` is the pole. `[4.5, 0, 0]` would land 4.5 behind the flat, inside whatever the spinner is mounted on. For a ring or a rule on a prism face, use `T.plane`.
```js
details: {
  seams: [s…],                                              // rings on rounds, ring seams on prisms
  ribs: { s0, s1, count, phase, twist, fade: [0.1, 0.55], seams: true, tone },   // knurl rows on rounds
  bolts: [{ s, r, count, phase, size: 0.5, tone: "mid", fade: [0.04, 0.42] }],   // bolt circles
  dots: [{ at: [s, x, y], normal, size, tone }],
  rings: [{ at: [s, x, y], normal, r, tone }],              // become planes
  rules: [{ points: [[s, x, y]…], tone, free }],            // become planes
  bevel: 0.4…1.6,                                           // prism cap bevel (faded slots)
}
```
Every detail fades with facing: dots and bolts by `smoothstep(0.04, 0.42, n·V)`, ribs per rib, bevel lines per edge, planes on the group's opacity. No texture stops at full strength.

### Runtime (`kit/turn.mjs`, inlined with `turnScript()`)
| Call | What it does |
|---|---|
| `TURN.mount(stage, data, { onHold, onFrame, follow })` | Binds every live layer in `stage`, renders the rest pose, installs `window.__isoTurn`. `follow(values)` maps figure values to group values on every `set` (one input drives coupled groups). `onFrame(controller)` runs after each render (draw shaders here). `onHold()` runs when a tool calls the hook. |
| `controller.set(values \| θ, { detail })` | Sets group values (a number sets the first turn group), renders synchronously, returns stats. `detail` (0…1) scales faded lines, ribs and dots (section 15). |
| `controller.angleAt(clientX, clientY, z, group)` | The pointer as an unwrapped angle about the group's pivot on the plane at height `z` (drag a dial). |
| `controller.pose(name)` | The group's world pose `{ R, t }`. |
| `controller.detach(name)` | The `{ world: true, R, t }` value that continues a free group's current pose in the world frame (no jump). |
| `controller.anchor(group, restPoint, restDir?)` | Where a rest-world point (and direction) of a group is now: `{ world, screen, direction }`. Spark sources, flame roots, LED positions. |
| `controller.cover(names, options)`, `order(layer)`, `orderIndex(layer)`, `events()`, `stats()`, `frames()`, `cams()`, `scene()`, `values`, `destroy()` | Cover masks (section 12), paint order, order-change events, `{ ms, writes, moved, forced, refined, unseparated, emitted }`. |
| `TURN.cover(controller, names \| [names, names…], { polygons = 6, edges = 10, pad = 0.3, layers })` | `{ uCoverEdge, uCoverCount, dropped, parts }` for `coverOf`. |
| `TURN.poseUniforms(controller, group, prefix = "uGroup")` | `{ prefixX, prefixY, prefixZ, prefixT }` such that `toGroup(p)` gives the group's **rest-world** coordinates. |
| `TURN.materials`, `TURN.materialCss()` | The material names; the material CSS alone (it is already in `TURN_CSS`). |
| `TURN.exact(on)` | Write path coordinates unrounded (6 decimals) instead of to 0.01 px. The orbit audit turns it on for the continuity line; a page never needs it. |
| `TURN.rotation(axis, degrees)`, `TURN.quat(q)`, `TURN.compose(A, B)` | 3×3 rotations as arrays, for free poses. |
| `TURN.floorOf(scene, group, R)` | The lowest z of a group's parts under rotation `R` (relative to its origin): to set a piece down flush. |
| `window.__isoTurn` | `{ controller, set, get, order, events, stats, layers }`; every call holds the figure (`onHold`). `set` takes a number or a values object. |

## 5. Part kinds and the rules that keep them true

These are binding; the build and the audit fail without them.
- **Convex parts only.** A stepped dial is tiers; a notched wheel is a hub disc plus rim sectors; a U-bracket is three prisms; a hand's shell is plates. `T.prism` checks its polygon, `T.lathe` its profile.
- **Contacts are flush, tangent or rim-on-flat.** A rim on a sphere is not separable: give the ball a flat (`T.ball({ flats })`) and land the rim on it. A pad sunk into its host is not allowed in a live layer (`separation` fails with `interlock A × B −0.6`).
- **Joints.** The two links stay at least 0.2 apart, and the joint is bridged by a knuckle disc on the child group: coaxial with the hinge axis, and touching the parent's link, flush or tangent. A disc coaxial with its hinge (or any flat face turning in its own plane) is invariant under the hinge, so it never collides, and it is the contact the kit audit needs. With nothing in the child group touching the parent, `rigid` reports every part of the child group as `support loose`. Links meeting at a joint share one radius; dome centres sit the same distance from the joint so the gap holds at any angle (two domes of radius r at distance c from the joint stay apart while cos(β/2) ≥ r/c). A trunnion on a yoke:
  ```js
  T.round({ name: "yaw.arm+", group: yaw, layer, F: VERT(0, 11.7), s0: 40, s1: 61, r: 1 });
  T.round({ name: "head.can", group: tilt, layer, F: frameOf([0, 0, 56], [1, 0, 0], [0, 0, 1]), s0: -12, s1: 8, r: 9 });
  T.round({ name: "head.trunnion+", group: tilt, layer, F: frameOf([0, 9, 56], [0, 1, 0], [0, 0, 1]), s0: 0, s1: 1.7, r: 4.5 });
  ```
  `tilt` hinges about y through `[0, 0, 56]`. The can reaches y = 9 and the arm starts at y = 10.7, so the links are 1.7 apart. The trunnion runs from 9 to 10.7: it sits on the can and its end face is tangent to the arm. Ending it at 10.5, with a 0.2 gap to the arm, fails `rigid` for the whole head.
- **No live line spans two moving groups.** A cable or hose crossing a flexing joint is two routes ending in a clip or port on each side of the joint, or crosses at the hinge axis through a rotary union (banjo). A cable that must reach a turning part from static ground goes up the axis through a slip ring at the hub (section 11).
- **Tubes bend nearly flat or inside fittings.** A bend of radius R in a plane tilted θ from horizontal, toward the viewer, is an ellipse on screen whose tightest radius is R·sin²(e − θ) (in world units; × `G.scaleOf(P)` for px). `folds` fails a bend that turns more than 90° on screen round less than 2r, and `self` one under r. So a bend needs **R ≥ 2r / sin²(e − θmax)**, where θmax is the plane's own tilt plus the pitch range of every hinge it rides on (a turn group sweeps the tilt through every azimuth, so the worst case always comes round). At e = 30°:

  | θmax | 0° | 5° | 8° | 10° | 12° | 15° | 20° |
  |---|---|---|---|---|---|---|---|
  | R ≥ | 8r | 11r | 14r | 17r | 21r | 30r | 66r |

  8r is the bound itself on a horizontal plane, not a margin: take 10r. Past about 15° no practical radius holds, so turn inside a fitting (clip block, banjo, elbow ball, a junction box) or run the line straight. Draw bends with `G.fillet(points, R)`, never the view-dependent fillet. The audit's `folds` and `self` run at every angle and pose.
- **Tubes never point along the view.** A tube's tangent elevation stays out of 15°–45° and −45°–−15° at e = 30° (`endon`), in every pose: on a hinge chain, check the link's pitch range too (a cable on a link that flexes 30° will fail).
- **Lathes are the one kind with small residual steps** (≤ 2.5 tone·px² where a band changes topology). Prefer rounds, domes and balls with flats for live parts.

## 6. Materials

A part's material is one option on its constructor (`material: "gold"`). Tones still come from facing exactly as the kit's do; a material remaps the ramp (the four shades, the top, paper, outline and line tones, dots and the tube body, shine and shade), with a light and a dark set each.

| Material | Look |
|---|---|
| `gold` | Champagne-gold anodised aluminium: warm pale top, bronze shadows, dark bronze outline. |
| `chrome` | Bright polished steel: a light far band, a dark reflection band (n·L 0.2–0.42) next to a bright band and a white highlight. Non-monotonic on purpose: the bands move with facing, never jump. |
| `steel` | Satin steel, monotonic and cool. |
| `gunmetal` | Dark blue-grey metal with light dots. |
| `rubber` | Black rubber and cable sheath with a soft sheen. With `hue`, a tube becomes a coloured sheath (the hue mixed into the rubber ramp). |
| `brass`, `copper` | Warm metals for fittings, ports, valves. |

Static parts use the same families as plain classes: wrap kit markup in `<g data-mat="brass">…</g>` (the example's index wedge). The rules live in `TURN_CSS`; a page with static art only can include `TURN.materialCss()`. A dark plate inside a light page picks the dark set (`.iso[data-theme="dark"] [data-mat]` is ordered last).

On a light page a material figure is far stronger than kit greys: keep the ground (bench, base, plates) in kit tones and put materials on the subject, so it reads as the lit, finished object.

## 7. Free groups: parts that break off

`T.group("pod", { parent: dial, free: { origin } })` makes a rigid body whose pose the figure sets every frame. At rest (`null`) it sits exactly where it was built, attached: the parent carries it. To break it off, start setting a pose relative to the parent: there is no single-frame change because the first value is the identity.
- **Falling onto the turntable**: keep the parent as the turntable group, so the piece lands and then turns with it. Compute the flight as a pure function of time (`breakAt(t)` in the example: a somersault about the centroid, a ballistic arc, a flush landing found with `TURN.floorOf`, a decaying slide and yaw). A pure function can be sampled by the build and the audit, and played backwards.
- **Falling off onto the bench**: switch to world space with `controller.detach(name)`, which returns `{ world: true, R, t }` continuing the current pose, then integrate from there.
- **Order**: a free group's parts are paired at runtime with every part of their layer (by screen box, then a GJK plane cached per relative pose); intra-group pairs are planar. Cycles among free parts are possible in principle; the audit samples the clips and reports them.
- **Proof**: give the build the clips in `poses()` (every frame of the break at several turn angles), and the audit the same clips (`clips`). The audit poses every shape in every clip frame and checks clearances against live and static parts (`posed`), order, cycles and continuity.

## 8. Hinge chains: hands and fingers

Each link is a hinge group whose parent is the previous link (`T.group("f0.j1", { parent: f0j0, hinge: { point: J1, axis: w, range } })`). A wrist is a hinge carrying the palm; a thumb base is a hinge (vertical axis) carrying the thumb chain. Drive the chain from one figure value through `follow` (`lift` → three angles).
- Pairs on different links are solved at build where possible: a pair whose swept volumes over the build's `poses()` have a plane in their common ancestor's frame with margin ≥ 0.1 becomes a **framed** plane (no runtime GJK). Only near-joint pairs stay dynamic (GJK, warm-started, cached while the two groups keep their relative pose). So `poses()` must cover the motion: if a gesture leaves the sampled range, the audit's `unseparated` line names the pair ("framed plane: … add it to poses()").
- Keep ranges physical and small: a three-link finger at ±5° per joint already reads as a hand moving; 20° per joint needs every route and contact re-proved.
- **A route on a hinged link carries the link's pitch range.** Section 5's `endon` rule and its bend bound both use the tilt the line can reach, not the tilt at rest: a horizontal 90° bend at R = 8.6r (inside the 8r rule for a flat plane) on a ±12° hinge fails `folds` and `self`, because at 12° of pitch it needs 21r. Make the line straight into a junction box, or move the bend to the parent.

## 9. Painter's order and why it is correct

Every pair of parts that can overlap on screen gets one relation: a plane fixed in their shared group (planar), a plane fixed in their common ancestor's frame (framed), a GJK plane each frame (dynamic, different groups that move relative to each other) or a skip (neighbouring chunks of one route). B is in front of A when n·V > 0 in that frame. Order is a stable Kahn sort keyed by the previous frame's order; on a stall, edges between parts whose exact screen hulls are apart (or touch within 0.1 px) are dropped; a remaining stall is a forced pick, counted and made fatal by the audit.

Correct because two disjoint convex solids whose projections overlap are both hit by some view ray, and a separating plane crosses that ray once. Flicker-free because a plane's relation flips only when the plane is edge-on, and then the two footprints lie on opposite sides of a line: swapping them changes no pixel. Edge-on relations (|n·V| < 1e-7) add no edge.

## 10. Shading and fades

- Flat faces (prism sides, caps, ball flats) use the kit's thresholds (`EB` for upright prisms, `LB` otherwise) through a smoothed staircase: a face holds the kit tone except within ±0.035 of a threshold, where it crossfades in 64 steps per band (`data-q`, 0…256). That fine step keeps chrome's high-contrast bands smooth.
- **The continuity line measures the unrounded geometry.** The page writes path coordinates rounded to 0.01 px, which moves an edge by at most 0.005 px, far below a device pixel. Measured on those rounded paths, a long edge's rounding steps never shrink, so the halving test once read large flat faces (the example's palm, a desk fan's 21-tall prism arms) as jumps that were not there. `orbit` therefore switches the runtime to exact coordinates (`TURN.exact(true)`) for the continuity line and back afterwards; large flat faces are fine on turning groups.
- Curved surfaces always use analytic iso-light bands (`labelledArcs`), never facets. Capsule domes are nested hulls; balls with flats are clipped caps. Bands are born at zero width.
- Faded-line slots hold ribs and bevels: alphas quantised to 1/32, as many slots as the build's orbit sweep shows a set needs.
- `lit: true` uses the lit tokens; with a material, the material's lit set.

## 11. Static layers and a messy workshop around a turntable

Every static item carries `layer` (`R.put({ …, layer: "back" })`) and the build proves a constant relation with every live layer over all reachable states, by the first rule that holds: a **horizontal plane** (the item is wholly under or over the layer's z range), the **cylinder rule** (the item lies wholly behind or in front of the turn group's swept cylinder along the view's floor direction), **never overlap** on screen, or a **swept-envelope plane**. Otherwise: `static S interleaves live layer Λ: split S at z = …, or move it`.

Authoring rules for a dense workshop (`examples/turning-dial/stress.mjs` proves 450 relations: bench, turntable base, power unit, tool rack, cable reel, tray, wrench, three bench cables):
- **Keep clutter outside the swept cylinder or under the turntable plane.** Tall things (power units, racks, lamps) go behind or in front of the cylinder of radius R (the live layer's reach from the axis) along the view's floor direction (cos a, sin a). Things beside it, neither behind nor in front, must be low.
- **Anything under the turntable's lowest live z is behind the live layer, even when it sits in front on the bench.** It belongs in a layer under the live one (`back`), not `front`: the build says so.
- **A cable that must reach the turning part** runs on the bench to the stationary base, climbs inside it and reaches the turning part through a slip ring or rotary joint at the hub. Draw the static run to a port on the base, and start the live cables from a terminal block on the turning platter. No route ever spans static ground and a turning group.
- Static cables lie flat on the bench (bends in the bench plane, R ≥ 8r) so they never fold, and end on a port, a reel or a free end marked `free`. A cable resting on the bench (centre at bench top + r) touches it, which the kit's `clearance` check fails (`close stand.feed × stand.bench clearance 0.00 < 0.40`) unless the route says so: `R.route(name, route, r, { owner: name, limp: true, touch: ["stand.bench"] })`.
- `stress.mjs` is a performance and layer-proof scene, not an audit-clean one: its bench cables have no `touch` and its hand has loose links, so the kit audit fails it. Copy authoring patterns from `build.mjs`.

## 12. Shaders that follow the turn and the groups

`kit/gl.mjs` gains (both opt-in; old fragments see nothing new):
- `GLSL_TURN`: `uGroupX/Y/Z/T`, `toGroup(p)`, `toWorld(g)`, `dirToGroup(d)`, `dirToWorld(d)`, and the cover masks `uCoverEdge[60]`, `uCoverCount[6]`, `coverOf(vb, soft)`.
- `glslPose(Name, prefix = "uName")`: the same four functions for any group (`toDial`, `fromDial`, `dirToDial`, `dirFromDial`), so one shader can follow several groups.
- `glLayer` sends arrays longer than 4 to `vec2`/`vec3`/`vec4` uniform arrays by their declared type (float arrays unchanged).

How a shader follows the motion:
1. **Content on a moving part** (an LED on a fingertip, a flame from a ruptured line's end, oil jetting from a pipe end): take the anchor every frame with `controller.anchor(group, restPoint, restDir)` and pass `world`, `screen` and `direction` as uniforms. Draw it on the **front canvas** (over the live layer) and occlude it with the parts painted after it: `const c = TURN.cover(controller, [partName]); layer.draw({ ...c, … })` and `ink *= 1. - coverOf(vb, .7 * px)`. Every live part is convex, so its hull is its exact silhouette; the cover is pushed out 0.3 vb so ink stops at the occluder's stroke, never in it. Several targets share the six polygons: `TURN.cover(controller, [["hand.f2.p3"], ["hand.pod.socket"]])`.
2. **Content fixed to a turning surface** (oil pooling on the turntable, a texture revealed by light on a dial): pass `TURN.poseUniforms(controller, "dial", "uDial")` and evaluate in rest-world coordinates: `vec3 q = toDial(onFloor(vb, FACE_Z));` The pool turns with the dial.
3. **Content in world space** (smoke rising, sparks after they leave the source, oil pooling on the bench outside the turntable): never touch a group transform. Emit particles from an anchor's world position at birth and integrate them in world space (the example's sparks): the turn doesn't drag them along.
4. **Back canvas** (light falling on a surface under the parts that cover it): put the canvas between two live layers. The example splits its dial into its own live layer `dial` under `hand`, so the LED's pool lands on the dial face and the hand covers it with no masks at all.

Every rule of `webgl.md` still holds: envelopes, windows to 0 before any bound, light-theme inks (saturated bodies, pale cores, tinted ground, ink only grows with light), dither, canvas edges at 0, an SVG fallback (`T.billboard` inside the owning part, hidden under `[data-gl]`, handed over with `(1 − u)/(1 − u·α)`). The cover chunk uses 70 uniform vectors: read `MAX_FRAGMENT_UNIFORM_VECTORS` and keep the SVG fallback below 128. `GLSL_TURN` defines `toWorld`: don't prepend it to a fragment that declares its own.

## 13. The orbit audit

`node build.mjs --audit` runs the kit audit, then `TA.orbitOrExit(T, R, P, options)`, which prints one line per check and ends `turn audit passed` or `turn audit failed: n problems` (exit 1). Only a full run ends `turn audit passed`: a run with `only` ends `turn audit partial: no problems in what ran (n lines skipped by only)`, and a quick one `turn audit quick: no problems in what ran (quick run, undersampled: the full run is the proof)`.

Options: `sweep: { at(x), from, to, frame }` (the turn), `clips: [{ name, at(t), from, to, frame }]` (gestures, the break-apart: every frame at 60 fps), `poses` (extra states), `crowd: [[route, part]…]` (pairs exempt from `crowding`, after you have looked), `cover`, `fittings: [[route, from, to]]` (stretches inside a fitting, exempt from `endon`), `quick` (≈ 10× fewer states, for iteration), `only: ["order", "rigid", "swept", "posed", "routes", "coverage", "continuity", "depth"]` (an unknown name throws), `log`. The example takes `--quick`, `--only=a,b` and `--verbose`. `only` runs these lines: `order` → `unseparated`, `cycles`, `forced`, `swaps`; `routes` → `folds`, `self`, `crowding`, `endon`; `depth` → `depth`, `layers`; the others their own line. `separation` always runs. A line that did not run prints `skipped`.

**Time and how to iterate.** The orbit audit is slow, and its time grows with parts × states. Measured on a 28-part desk fan (one turn, one hinge, one clip): `--quick` 85 s, full 17 minutes. The example scene takes several minutes; budget about 30–40 s per part for a full run. That is past a 10-minute shell timeout, so:
1. Iterate with `--quick --only=…` on the lines you are fixing.
2. `--quick` alone before the final run. It undersamples: it checked 100 halving candidates against 800 in the full run and missed 20 `continuity` failures the full run found.
3. The final full run in the background (`node build.mjs --audit > audit.txt 2>&1 &`), read when it ends. Only that run's `turn audit passed` counts.

| Line | Fails on | Fix |
|---|---|---|
| `separation` | A same-group pair with no plane (margin < −0.08) | Split, a flat, or a flush contact |
| `unseparated` | A cross-group pair interpenetrating while their hulls overlap; a framed plane that a state breaks | Fix the contact or the motion; add the motion to `poses()` |
| `cycles` | A cycle among overlapping parts in some state; prints the parts, the state range and the split plane (the cycle edge with the largest margin) | `T.split` that part on that plane |
| `forced` | The runtime had to force a pick | As cycles |
| `swaps` | Two overlapping parts swap order while overlapping more than 0.25 px² (0.5 px² for a pair built flush, margin under 0.05: at the instant their contact plane is edge-on, the true overlap is a line, and the drawn curves leave a sliver about 0.1 px wide) | Look first: `drive.mjs page.html '[["orbit", "swap.png", θ−0.3, θ+0.2, 0.1, null, 6, [x, y, w, h]]]'` (θ from the line; the region round the pair in CSS px of the plate, at scale 6). Something that appears or vanishes in one step is a drawing bug, not an order bug. Engines before 2026-10-06 drew a round with one `"dome"` end shorter than its radius with a wedge behind its flat end, which vanished when that end went edge-on: copy the current `turn.mjs`. A clean swap with sane order on both sides (`__isoTurn.order`) is a wrong plane: report it with the part pair and θ |
| `layers` | A static item on the wrong side of a live part (ray-cast) | Move it or split it (section 11) |
| `rigid` | Kit clearances, terminals, supports, solid clearances at rest | As in the kit audit. `support loose` on every part of a hinged group: nothing in it touches the parent (the knuckle disc, section 5). `terminal … from X`: the route starts at a port's face, not the mount (the `T.tube` row, section 4). `clearance … × stand.bench`: a bench cable without `touch` (section 11) |
| `swept` | A static shape inside a turn group's swept envelope | Move it out of the cylinder |
| `posed` | Shapes of different groups (and moving live × static) passing through each other in any sampled state | The motion or the geometry |
| `folds`, `self`, `endon` | Live routes at every angle and posed state | Section 5 (bend radius with the pitch range, section 8) |
| `crowding` | A live route's end or bend lands on the outline of a slender solid (a rod, post, stem) at some angle | A line parallel to a turning rod crosses the rod's outline at some angle whatever its offset, so you can't design it out. Look at the angles it names (`["turn", θ]` and a 4× shot), and if it reads as two separate parts, list the pair in `crowd: [["cable.a", "yaw.stem"]]`, or route the line inside the rod. A line that is not parallel: approach across the rod |
| `coverage` | A part's recorded shape covers < 60% of its drawing | Record the true shape |
| `depth` | The ray-cast oracle disagrees with the engine order | Report it |
| `continuity` | Per 0.05°: tone step > 1/16 band, a birth > 2 px², a dot α step > 0.01, a faded α step > 1/32 + 0.005, outline motion over 1.5·ω·Δθ + 0.05 px; and the halving test | A detail that stops: fade it. A part whose outline or face boundary jumps in one step: find the angle with an orbit sheet at 0.1°; on a plain kit primitive it is an engine bug, report it with the part and θ. `--quick` undersamples: only the full run counts |

The halving test re-emits each candidate step at ½, ¼ and ⅛ of itself with exact coordinates, rasterises the frames at 4× (jittered samples on 8 sub-scanlines) and measures the pixels that change owner, weighted by tone. A smooth change halves with the step; a jump doesn't. A step is a discontinuity when it stays above 60% at each halving and is still ≥ 2 tone·px² at ⅛, measured at 4× and again at 16×: a step that fails at 4× fails only if it also fails at 16×. Measured on the page's rounded paths instead, every long edge keeps a floor of about its length × 0.01 px that never shrinks; that floor is what flagged large flat faces before the switch to exact coordinates. Every line has a planted fault that fails it (`3d/engine/plants.mjs` in the build session: a sunk pad, a pinwheel of three tilted sticks, a vertical-plane fibre bend, a tube at 30°, a stopped rib row).

## 14. Browser checks

```sh
node examples/turning-dial/build.mjs --light --verify           # writes turning-dial-verify-light.html
node scripts/turn-check.mjs examples/turning-dial/build.mjs --fidelity
node scripts/turn-check.mjs page-verify.html --order --step 1
node scripts/turn-check.mjs page-verify.html --order --states clip.json
node scripts/turn-check.mjs page.html --pops --no-webgl --step 1 --out shots/pops
node scripts/turn-check.mjs page.html --lines                    # and the dark page
node scripts/turn-check.mjs page.html --perf --width 1440
node scripts/turn-check.mjs page.html --perf --width 390 --throttle 4
node scripts/turn-bench.mjs examples/turning-dial/build.mjs
node scripts/drive.mjs page.html '[["turn", 37], ["shot", "a.png"], ["orbit", "orbit.png", 0, 345, 15]]'
```
- `--fidelity` (Node): prisms against `k.extrude`/`G.prismOf` (outline, top and faces each ≤ 0.06 px), rounds and lathes against `G.lathe(…, smooth)` (outline ≤ 0.05 px against the kit drawn at 10×, bands ≤ 0.5 px; the line also prints the outline against the kit's own paths, which round to 0.1 px, so 0.08–0.13 px there is expected and not judged), tubes against `tubePieces` (0.05 px), at P′(θ). Control: one prism drawn 2° off must fail.
- `--order`: the recorded shapes in a WebGL z-buffer against the DOM painter's fills, 2×; a live pair with 4+ interior pixels fails. Control: each live layer reversed must give ≥ 1000 px. Static × live mismatches are listed (static recorded shapes are often stand-ins, such as a slab for a plate with a window). `--part name` prints a part's footprint in both images. `--step` sweeps the **first turn group only**, with every other group at rest: a hinge, a slide or a clip needs `--states`.
- `--pops`: first difference (θ → θ + 0.02°, two erosions) and second difference (0.1°, |2b − a − c| > 56, or > 6 on flat pixels, one erosion; a core of 6 px fails). Control: the largest part hidden for one frame. `--out dir` saves the first core's frames. With `--states`, consecutive entries are treated as neighbouring frames and only the first difference runs, so the list must be **one continuous path in steps of about 0.02°**: 529 unrelated yaw × tilt poses gave 404 false failures; a tilt sweep of 1201 states at 0.02° gave 0.
- `--states file.json`: a list of value objects instead of angles. Write them from the build module, which exports `follow` and the clip functions:
  ```js
  import { writeFileSync } from "node:fs";
  import { follow, nodAt } from "./build.mjs";
  const at = (t) => follow({ yaw: 20 + 30 * t, tilt: nodAt(t) });
  writeFileSync("clip.json", JSON.stringify(Array.from({ length: 241 }, (_, i) => at(i / 60))));
  writeFileSync("tilt-path.json", JSON.stringify(Array.from({ length: 1201 }, (_, i) => follow({ yaw: 30, tilt: -12 + i * 0.02 }))));
  ```
  `clip.json` (every 60 fps frame of the clip) is for `--order`; `tilt-path.json` (one hinge swept at 0.02°) is for `--pops`. Run `--pops` on one path per hinge, at a turn angle where the hinge's parts overlap the most.

## 15. Performance

Measured by `turn-bench` (Node CPU) and `turn-check --perf` (browser, 240 frames at 1.1°).

**What a part costs.** `turn-bench` prints the Node time per part by kind. In prism units (one live prism, about 0.03–0.04 ms per frame on the build machine):

| Kind | Cost | Kind | Cost |
|---|---|---|---|
| `fixed` (vertical rounds, plain balls) | 0.5 | `round` | 2.5–4 |
| `prism` | 1 | `ball` with flats | 8–10 |
| tube chunk | 2–3 | `lathe` | about 25 |

A figure's weight is the sum. A **small figure** weighs up to about 50 (a few dozen parts, mostly prisms and fixed pictures); a **dense** one up to about 230. The desk fan (28 parts: 6 prisms, 7 fixed, 5 tube chunks, 8 rounds, 2 balls with flats) weighs about 65, and measured 1.6–1.9 ms of Node CPU per frame and a 6.0 ms browser task at 1440, so it is over the small-figure line, mostly because of its two balls. The turning dial (88 parts, 42 rounds, one lathe) weighs about 225 and measures 6.4–7.2 ms. A ball with flats is the right part for a domed end with a rim landing on it (section 5), but it costs as much as nine prisms; a plain ball or a vertical round is a fixed picture and nearly free. Times vary by machine and load: compare kinds within one run, and measure your own figure.

| Measure | Budget for a small figure (weight ≤ 50) | Budget for a dense figure (weight ≤ 230) |
|---|---|---|
| Node CPU per frame | ≤ 2 ms | ≤ 7 ms |
| Browser main-thread task at 1440, DPR 2 | ≤ 6 ms | ≤ 13 ms (60 fps) |
| Main thread at 390, DPR 3, 4× CPU | ≤ 16 ms | ≤ 33 ms (30 fps) |
| `data.turn` JSON | | ≤ 250 KB |

Levers, in order:
1. `fixed` for every vertical-axis turned part and every plain sphere (automatic).
2. Planes for every set of lines or text on a face; upright prism tops are one matrix.
3. Small parts sample by device pixels: rings, caps and domes of a part under 30 px round take 12–16 samples, never the large-part minimums.
4. **Framed planes and the relative-pose cache** (automatic): cross-group pairs cost nothing while their groups keep their relative pose (a pure turntable turn), and pairs whose motion the build sampled are framed planes.
5. **Adaptive half rate** (the figure's loop): keep an exponential moving average of `controller.set(...)` time; above 14 ms render the live layers on alternate frames (time-based motion unchanged), below 9 ms every frame again (`stress.mjs`'s loop).
6. **Detail by speed** (opt-in): `controller.set(values, { detail })` with `detail = settle(1 − smoothstep(120, 240, |ω|°/s), 0.25 s)`: faded lines, ribs and dots scale and are skipped below 0.01. Outlines and tones are never reduced.

Authoring rules that keep a figure inside budget: about 150–250 live parts per moving group; small details as `details` (dots, rings, bolts) rather than parts; static clutter as static art; one live layer per independently moving region; tubes chunked at 6 with as few chunks as the bends allow. The measured numbers for the example and the stress scene are in the skill's build notes; a 380-part flexing hand on this machine is a 30 fps figure at 1440 with the half-rate lever, not a 60 fps one.

## 16. Known limits

- Vertical turn axes take the azimuth shortcut; other rotations compose matrices.
- Only convex parts. Concave bodies are convex pieces; ball flats replace rim-on-sphere joints.
- Live tubes obey the fold and end-on rules: there are no swept-tube silhouettes, so turns happen flat or in fittings, and no route spans two moving groups.
- No automatic splitting: a cycle is an audit failure with a suggested plane.
- Flat faces crossfade over ±0.035 of score (about 8° of turn).
- Lathes keep small residual steps where a band changes topology.
- Free groups pair at runtime with their whole layer; dozens of debris pieces cost GJK while they move.
- Phones: the budget aims at 30 fps, not 60, and only a figure inside it gets there. The 380-part stress scene (`examples/turning-dial/stress.mjs`) measured about 20 fps at 1440 and 13–15 fps at 390 with 4× CPU throttle, with the half-rate lever, on a heavily loaded machine. There is no canvas painter.

## references/craft.md

# Craft

How a figure comes to read as a crafted object rather than a diagram. Every rule here came from a figure that was rejected or approved by a demanding reviewer.

## Contents
1. What "crafted" means
2. Anti-patterns
3. Parts vocabulary, with recipes
4. Tones
5. Painter's order
6. Camera and composition
7. Themes
8. Round parts and pipes
9. Exploded views
10. Screens and things on faces
11. Shaders

## 1. What "crafted" means

The approved figures share five qualities.

1. **A real object you could build.** The reader names it at a glance: a test rig, a dial indicator, a chest of drawers, a gantry plotter, a lock. It is invented for the subject, never traced from a reference.
2. **Many small, precise parts.** A good inline figure has 30–60 solids and 300–500 paths. A hero subject that really has more parts can carry far more: the desk computer has about 85 solids, and the Raptor about 270 solids in 478 depth-sorted items. Every part rests on another part, aligns to its neighbours and has a job. Density comes from structure (feet, rails, stops, bushings, knobs), not from texture.
3. **Volume.** Parts have real thickness, sides shaded in four tones, lighter tops, bevels that catch light, and a soft halo underneath.
4. **Calm.** Thin 0.6 px lines and a restrained palette. One lit accent tells the eye where to look. Motion is eased and slow enough to follow.
5. **Truth.** The readout shows live, correct numbers, and the motion follows the real model: springs with real stiffness, true sizes and thresholds.

## 2. Anti-patterns

Reviewers rejected each of these, so don't do them:

- **Abstract stacks.** Floating plates, slabs or tiles standing for "layers" are too abstract to read as anything. If you need layers, make them drawers, trays in a rack, plates on guide pillars, or cards in a holder.
- **One big plain slab.** A large surface with a few lines is too flat and bland. Break it into a base, a carriage and parts; add a groove, screws, a ruler and a label plate.
- **Blueprint furniture.** Leader lines, balloons with numbers, dimension arrows, hatching, dashed hidden edges and floating labels belong in the legend and caption, not the drawing.
- **Lines that stop in mid-air.** Guide fans, connectors that end short of a part, or a crease that stops before the silhouette corner. Every line must land on something.
- **Wireframes.** See-through boxes where the back edges show.
- **Colour everywhere.** Keep it monochrome unless colour is the subject: one lit accent plus true RGB only for colour channels. A fluid is not colour by default: oil in a sight glass, water in a tank and coolant in a pipe are tints of the figure's own inks, and the `--no-webgl` fallback draws the same tint the shader does. A blind-test bench painted its oil saturated orange in an otherwise grey figure, and its fallback oil was grey.
- **Text in the drawing.** Allow at most one tiny engraved label (`FaceText` on a face). Everything else goes in the card corners, legend or caption.
- **Copying a reference.** Take mood and finish from a reference, then invent the object.
- **Raw pointer jumps.** Parts that snap to the cursor. Ease everything.
- **Pipes through pipes.** On the first Raptor the owner zoomed in and found cables and lines passing through the main ducts and through each other, "which makes no sense". At 1× it looked fine. Every pipe and cable keeps clear of everything but its own connection, every crossing is drawn in true depth order, and `kit/audit.mjs` proves both (section 8, "Clearance and crossings").
- **Textures that stop.** On the same close-up, rows of bolt dots on the chamber stopped dead in the middle of the surface, and the bell and throat were shaded as a staircase of flat tone blocks. Both read as broken. The owner's words: if a texture has to end, "we'd rather fade them out at the end than an instant stop". Shade curved surfaces smoothly, and make a texture end on a real edge or fade out (section 8, "Shading and textures on curved surfaces").
- **Shaders that pop or are cut.** The first Raptor plume changed colour instantly between its phases, "as if cut out", and a glow ended in a straight line at the canvas edge. Every shader term rides an envelope and reaches zero before any bound (`webgl.md`, section 7).
- **Textures that stop, on flat faces too.** The same fault came back on a later figure's boxes, where no curved-surface rule reached it: a tank's weld seam inset 6 units from each rounded corner, a manifold face line inset 4, a row of faint ticks floating on a bare bedplate with no ruler under them, cooling ribs that stopped 2 units short of the seam arcs at both ends, and a hose braid drawn as a dashed stroke down the centreline that ended in two straight hard edges along the hose and broke at every chunk joint. A seam on a box runs edge to edge round the visible corners (`k.sideSeam`), ribs end on seams at the same `s` (`ribsOf(…, { seams: true })`), ticks hang from a ruler strip's edge, and a texture on a pipe comes from `tubePieces(…, { rings })`. `node scripts/lines.mjs page.html` fails every line end that touches nothing (`verify.md`, "Line ends").
- **Stand-in shapes.** To quiet the audit a builder recorded a 0.45-thick disc at the tip of each of five overlapping rod copies, and a sliver for a second rod that was drawn sunk into its cylinder head. The audit passed and said nothing about either rod, and the lit rod's outline was notched every 12 units while it moved. Record the true shape of what you draw; the audit's stand-in check fails an item whose recorded shapes cover less than 60% of its drawing. A rod that slides out of a gland is one rod (section 3, "Rod out of a gland").
- **Dark-theme glows pasted on white.** The owner made light the shipping theme for every site figure, and seven shader figures built on a dark plate had to be converted. Taking a dark glow and darkening it gave "red ink smoke" (the Raptor's light plume). It also gave flames drawn as outlines, sparks that looked like dirt, salmon-coloured cooling glass, grey fades and cavities that got heavier as the fire went out. On paper, light reads through saturation and a darker physical ground (section 7, and `webgl.md`, section 8).
- **Fixing one close-up by eye.** Tuning a painter's key or nudging a waypoint until one screenshot looks right breaks another frame or another theme. The Raptor's hand-set keys looked right at 1× and had 323 depth-order problems. Measure (section 8, "Clearance and crossings"), then look.

## 3. Parts vocabulary, with recipes

`P` is the projection and `k` is the kit, as in `import * as k from "./iso-kit"`. Heights are in world units, with z up. Draw each recipe's solids in the order listed.

### Base plate on feet
```js
const PLATE = { x: -128, y: -64, w: 262, d: 128, r: 8 };
halo:  `<path class="iso-halo" d="${k.haloOf(PLATE, -17, 12, P)}"/>`
feet:  for (const [x, y] of byDepth(k.corners(PLATE, 22))) {
         k.cylinder(x, y, 7, -17, 1.6, P, 28)            // flange, tone lo
         k.cylinder(x, y, 5.4, -15.4, 3.4, P, 28)        // foot, tone mid
       }
slab:  k.slabOf(PLATE, -12, 12, P, 8, 1.6)                // tone mid, bevel 1.6
groove: k.planOutline(k.insetPlan(PLATE, 5), 0, P)         // line lo
screws: k.corners(PLATE, 10) → k.ring(x, y, 2.2, 0, P, 16) (line lo) + dots size .5
```
Sort feet with `byDepth = (pts) => [...pts].sort((a, b) => k.depthOf([a[0], a[1], 0], P) - k.depthOf([b[0], b[1], 0], P))`.

### Ruler strip and label plate
```js
const strip = { x: -110, y: PLATE.y + PLATE.d - 15, w: 230, d: 7, r: 1 };
k.slabOf(strip, 0, 0.6, P, 3)                                   // tone lo, crease none
const t = k.topTicks(strip.x + 4, strip.x + strip.w - 4, 4, 5, strip.y + 0.8, 0.6, [2, 3.6], P);
t.minor → line lo, t.major → line mid
label plate: k.slabOf(plan, 0, 0.8, P, 4) + k.planOutline(k.insetPlan(plan, 1.6), 0.8, P) (faint)
             + three k.lineOnTop rule lines of different lengths (lo, { free: true }) + two screw dots
```
Ticks only live on a ruler strip or a dial face, rooted on its edge: `topTicks` grows each tick from `edge`, so put `edge` on (or within 1 unit of) the strip's edge, and `sideTicks` hangs them from `top`, so pass the face's top edge, not 2 units below it. A row of marks on a bare plate is a texture that floats at both ends. The label plate's rule lines stand for printed text, so they are the one place a line may stop on a bare face: pass `free: true` so the line check knows it was meant.

### Seams, welds and trim lines on a box
```js
const seam = k.sideSeam(TANK, 14, P, 6);                         // visible near half of the plan at z = 14, round the corners
k.solidSvg(k.slabOf(TANK, 6, 98, P, 6, 1.2), { tone: "mid", inner: k.lineSvg(seam, { tone: "faint" }) })
```
A seam, weld bead, band or lid line on a box wraps the visible corners and ends exactly on the vertical silhouette edges, like `sideArc` on a cylinder. Pass the slab's own `steps` so the seam follows the same facets, and pass it as `inner` so its ends sit under the outline. A line that belongs to one face only (a stiffener, a panel joint) runs the full face, edge to edge: `segment([x, y, z0], [x, y, z1])` with `z0` and `z1` the face's bottom and top, never `z0 + 3`. Slots and vents are closed outlines (a `planOutline` of a thin rounded plan, or the same in a face plane), so they have no ends at all. Never inset a line by a few units from its corners: at 4× it reads as a texture that stops.

### Rod out of a gland
A piston rod, a plunger or a slide whose visible length changes as it moves is one rod, built once at full length (visible plus stroke), with the part inside the gland clipped away:
```js
const ROD = G.rodOut(F, gland, tip, r, P, { stroke });           // F.a points out of the gland
svg = `<clipPath id="rod"><path data-hold d="${ROD.clip}"/></clipPath><g clip-path="url(#rod)">${k.solidSvg(ROD.paths, { lit: true })}</g>`
R.put({ ..., move: G.mul3(F.a, stroke), svg, shapes: [R.solid(A.slide(F, gland, tip, r, { name: "rod" }))] })
live: el.setAttribute("transform", `translate(${dx} ${dy})`); holdStill(el, [dx, dy])   // dx, dy: the item's screen offset this frame
```
`rodOut` returns the rod's paths from `gland − stroke` to `tip` and a clip that covers it from the gland face to `tip + stroke`. The clip path carries `data-hold`; whatever moves the item calls `holdStill(el, [dx, dy])` with the same screen offset, which moves the clip the other way, so the gland face stays put while the rod slides through it. The same helper retracts a rod into a gland (a load cylinder) when its item moves the other way. Draw a faint seal ring (`circleOf` at the gland face, radius `r + 0.4`) on the gland. Record it with `A.slide`, which the audit poses by stretching the rod from the gland face instead of translating it, so clearances, depth and solid checks see the true rod in every frame. Never fake the extension with overlapping copies: each copy's lit edge stops where the next one's fill starts, and the outline is notched every few units while it moves.

### Rails, end stops and a carriage
```js
rails:  for y of [-20, 15]: k.slabOf({ x: 2, y, w: 122, d: 5, r: 1 }, 0, 3, P, 3, 0.4)   // mid
stops:  k.slabOf({ x, y: -23, w: 5, d: 46, r: 1.2 }, 0, 7, P, 3, 0.5) + 2 screw dots on top
carriage (built at x = 0, moved with translateAlong):
        k.slabOf({ x: -28, y: -24, w: 56, d: 48, r: 3 }, 3, 5, P, 5, 0.6)
        bushings: small slabs at the four corners, taller than the plate, with screw dots
        rivets: a row of 0.4 dots along the front edge
        handle: a small neck slab plus a knurled cylinder on the front face
```

### Post, collar, clamp and thumb screw
```js
k.cylinder(px, py, 5, z0, 4, P, 28)              // collar
k.cylinder(px, py, 3.2, z0 + 4, h, P, 28)        // post, crease none
k.sideArc(px, py, 3.2, z, P, 16)                 // faint seam rings on the post
k.slabOf({ x: px - 7, y: py - 7, w: 14, d: 14, r: 2.5 }, zc, 12, P, 4, 0.6)   // clamp block
thumb screw: a 2.8 × 3 neck slab from the clamp face + k.cylinder(r 2.6, h 6) + k.knurl(..., 18)
```

### Knurled knob with a pointer
```js
k.cylinder(cx, cy, 6, z, 4, P, 32)                          // mid
k.knurl(cx, cy, 6, z, z + 4, 36, P)                         // line lo
k.lineOnTop([cx - 3, cy], [cx + 4.6, cy], z + 4, P)         // pointer, line hi
```

### Coil spring around a rod (split for painter's order)
```js
const c = k.coil(xFrom, xTo, [rodY, rodZ], 4.2, 6, "x", P);
c.back  → line lo, drawn BEFORE the rod
rod     → k.slabOf({ x, y: rodY - 2, w, d: 4, r: 1.8 }, rodZ - 2, 4, P, 3)
c.front → line mid, drawn AFTER the rod
```
Rebuild the coil each frame when it compresses, by changing `xFrom`/`xTo`. It is cheap.

### Dial gauge (face up)
```js
k.cylinder(x, y, R, z, 10, P, 64, 0.8)                       // puck, tone hi, crease lo
k.knurl(x, y, R, top - 3.4, top, 84, P, 0.3)                 // knurled bezel band, lo
k.sideArc(x, y, R, top - 3.4, P, 40)                         // band seam, faint
k.ring(x, y, R - 2.2, top, P, 64)                            // bezel ring, lo
const t = k.radialTicks(x, y, R - 3, 50, 5, top, [1.3, 2.8], P)   // minor lo, major mid
crown: two small slabs on the +x side; lug: a slab joining the arm
needle: k.lineOnTop(tail, tip, top + 0.2, P), tone lit, stroke-width 1.1, rebuilt per frame
hub: k.ring(x, y, 1.6, top, P, 16)
```

### Drawer with a pull and label holder
```js
case:   k.slabOf(casePlan, z, h, P, 4, 0.8)
front:  a thin slab on the near face; pull: two posts plus a bar (small slabs)
holder: an inset outline on the front with a card slab inside; rivets as 0.45 dots
open:   translate the drawer along its axis with translateAlong; its box (floor plus 3 walls)
        is drawn before the front, and its contents are engraved on the floor
```

### Turntable with degree ticks
```js
k.cylinder(cx, cy, R, z, 4, P, 72, 0.8)
k.radialTicks(cx, cy, R - 1, 72, 6, z + 4, [1.2, 2.6], P)
rotate: rebuild only what turns (or the object on top) at the new angle each frame
```

### Perforations, vents and a pulsing dot grid
```js
k.dotGrid(x, y, cols, rows, gap, zTop, P) → dotsSvg(points, { size: .45, tone: "lo" })
// add { pulse: true } for a slow pulsing grid on an "active" part
```

### Lamp, probe and finger
These are stacks of cylinders: housing, cap ring, collar, stem, pad. Seam rings come from `sideArc`, and the lock screw is a small slab plus a cylinder head on the side.

## 4. Tones

| Element | Tone |
|---|---|
| Base, feet, floors | `lo` |
| Structure: posts, rails, frame, carriage, case | `mid` |
| The subject: the thing being explained | `hi` |
| The part the reader should watch now | `lit` (one only) |
| Grooves, inset outlines, floor grid | `faint` |
| Minor ticks | `lo` |
| Major ticks | `mid` |
| Zero mark, pointer line | `hi` |
| Screws and rivets | dots `mid` at 0.4–0.55 |
| Crease (top/side join) | `faint` (default), `lo` on hi parts |

A `Solid` with `lit` gets a white edge and a lighter top. Use it for the moving subject, the plunger or a picked drawer.

## 5. Painter's order

- Draw the base first, then everything resting on it.
- Within a level, sort by `depthOf(centre, P)`, ascending: far before near.
- **Split wrapping parts.** For a spring or a frame around a carriage, draw the back half, then the inner part, then the front half. In the test rig: back frame bars → rods with the back coil halves → carriage → glass → front coil halves → front bars → dial.
- **Moving parts** are drawn at their slot in the order, inside a `<g transform>` that moves them. If a moving part can pass in front of and behind the same neighbour, split the neighbour or bound the motion so the order never changes.
- **Things standing on a stepped surface.** Order the steps so the taller ones are further back. A probe resting on a lower step then can't be hidden by a taller one in front of it.
- Vertical parts that pass through a horizontal one (a post through a clamp) are drawn in two segments, below and above, around the horizontal part.
- **A clip cut is an edge.** Where a clip ends one part against another (a cable-chain run entering its loop, a rod at a gland), make the clip's section flush with the joint plane, and draw a stroked edge along the cut that moves with the clip (`data-clip`). On the plasma cutter, a run's clip section reached past the joint. The run's top face and slot bars were drawn 26 units into the loop's hollow and ended on a vertical cut with no stroke, which is a line stopping in mid-air. Re-ordering the painter's keys wouldn't have fixed it: the loop link's end face really is behind the run.
- **Dense figures settle their keys from geometry.** Past a few dozen overlapping parts, hand-set biases fight each other. Give every item its true shape and let `kit/audit.mjs` measure which surface is nearer wherever two items overlap, then `orderKeys` raises each key just enough to satisfy every pair (`kit.md`, section 12). What it can't satisfy is a pair that interlocks, which needs a split. The first Raptor's hand-set keys left 190 pairs drawn the wrong way round assembled and 133 apart; measured keys took both to zero.
- **Crossings sort per chunk.** A pipe or cable is drawn as `tubePieces` chunks, each its own item at its own depth, so one line can pass in front of one part and behind the next. Where two lines cross on screen, the chunk nearer the camera is drawn later; a crossing can only be ordered if neither chunk also runs behind the other somewhere else, so break long chunks at a crossing (`breaks`).

## 6. Camera and composition

- Default camera: azimuth 45°, elevation 30°. Rotate the azimuth (35–60°) so the face that carries the mechanism points at the viewer. The test rig uses 57° to show the long side. Raise the elevation to look into something open: the ripple tank uses 44° so the water surface and the paper under the tank both read.
- Fit with `fitProjection(boxCorners(...) ∪ tallest points, width, height, { pad: 24–34, azimuth })`. Typical viewBoxes are 600 × 340–380.
- The object fills about 55–65% of the card. Leave air around it, don't let it touch the corner labels, and keep sizes consistent across a set of figures.
- On phones (390 px), check it still reads. If parts get too small, scale the SVG up to about 110–118% inside a card with `overflow: hidden` and a negative margin, as `stretch.css` does in the test-rig example.

## 7. Themes

`ISO_CSS` defines tokens on `.iso`, with dark as the default and `.iso[data-theme="light"]` for light. Every fill and stroke reads a token, so a theme is just a token set. To tune one:

- **Paper** (`--anatomy-paper`) equals the card colour, so fills hide what is behind without showing as patches.
- **Shades.** Four steps from darkest (0, facing away from the light) to lightest (3). Keep them close: about 3% apart in luminance.
- **Lines.** In dark mode, `hi` is near-white and `faint` barely above the paper. Light mode inverts this.
- **Halo.** A blurred dark fill under the base: strong in dark mode, about 30% in light.

**Light ships.** Every figure on the isometrics site is light, and that includes its shaders. Design light first and keep dark working where that is cheap. What the seven shader conversions taught about the drawing:
- **Every colour is a token.** A colour typed into a figure's CSS or its shader is a theme bug waiting to happen. Give it a light value and a dark override (`.x { --x: … }`, then `.iso[data-theme="dark"] .x { --x: … }`). Where the shader needs the same colour, read it from the token (`webgl.md`, section 8.3).
- **Cavities get their own token set.** The inside of a furnace mouth, a ladle, a vessel seen through a cutaway, a water column or a duct window is darker for a reason: soot, refractory, depth, shadow. Give its group a class that redefines `--anatomy-paper`, `--anatomy-face`, `--anatomy-top`, the four shades and the line tones to darker, warmer or bluer greys. Keep all the tones so it still has form, mix it toward the light's hue by a variable the live code writes, and set the tokens to `inherit` in dark. Glows then have a ground, without a black box.
- **Mind nested themes.** A dark plate can sit on a light page. A rule written `.iso:not([data-theme="dark"]) .x` also matches through the page around it. Order the dark rule after the light one, or key on the plate's own theme.
- **Opacity fades a pale colour to grey.** Anything that fades by opacity in light (a fallback glow, a lit accent) gets a saturated token, and its tints are multiply-blended so the lines under them stay visible.

## 8. Round parts and pipes

Engines, pumps, valves, lamps and instruments are mostly turned parts and pipes. `kit/lathe.mjs` and `kit/tube.mjs` build them along any axis (`references/kit.md`, sections 10 and 11). `examples/raptor-engine/build.mjs` has every recipe below in working form.

### Turned parts: think in meridians
Write a part as its half-section: a list of `[s, r]` from one end to the other, and turn it with `G.solidOf(profile, F, P)`.
- **Steps and shoulders** are where the radius jumps at one `s`: `[6, 12], [6, 9]`. They make the crease lines that read as machining.
- **Curves** are many points marked smooth (`[s, r, 1]`), so no crease is drawn across them: a bell, a dome, a fillet into a flange. The kit shades any profile with a smooth vertex smoothly on its own (`smooth: "auto"`), so the shading follows the curve instead of stepping facet by facet; pass `smooth: false` only to opt out.
- **A waisted body is never hulled.** A profile that narrows in the middle (a throat, a spool) has a concave outline. `lathe` notices when its true silhouette lies more than half a pixel inside the hull of its rings and then fills and outlines it with the silhouette and the visible end arcs instead (`fill: "auto"`, or force `"surface"`). Drawn as a hull, the Raptor throat was a straight cone with a pale unshaded strip and a seam that stopped 8 px short of the outline.
- **Seams and weld lines** on a curved surface are `G.arcOf(F, s, r, P, { slope })`, with the profile's slope at that ring, drawn `faint` and passed as the solid's `inner` (`solidSvg(paths, { inner: lineSvg(arcOf(…), { tone: "faint" }) })`; the Raptor's `S` and `Ln` are one-line wrappers over these), so they sit under the outline. They end exactly at the limb, a little short of it. Concatenated after the solid, each seam's round cap lands on the edge stroke and the dark-theme outline of the Raptor's actuator barrels looked dashed at 4×.
- **A long body** (a nozzle, a barrel) is cut into bands with `G.bandsOf(profile, cuts, F, P, { radius, slope })`, so each band depth-sorts against the pipes that wrap it. The bands share one `steps`, one normal field and one surface, their own outlines come cleared, and `sidesOf` draws one continuous outline for the whole body (`kit.md`, section 10), so the tone curves run through every joint without a step and the sides don't kink. Without an analytic `radius` and `slope` it fits one curve through the whole profile, never one per band.
- **A ring around a band** (a stiffening ring on a chamber, a collar) splits the band: band, ring, band. Drawn as one long band with the ring on top, the ring's back half paints over the band (or the band hides the ring's front); split, each piece orders itself. The Raptor chamber jacket is three bands between its two rings.
- **An open end** is a `ringBand` lip, with a filled void (`circleOf`) for the dark inside and the inside arc from `arcOf(..., { inward: true, least: -1 })`. Bolt dots go around the lip.

### Parts on a round body
- **Flange:** its back face sits on the route end, so the route ends exactly on the mount's surface and the disc runs from there back along the pipe (`centre = end − tangent · t/2`). `disc(-t/2, t/2, r, frameAlong(centre, axis), P)`, with a bolt circle `dotsOf(F, faceS, 0.74r, 8, P, { all: true })` on the face that points at the viewer. Skip the bolts when the face is edge-on (`|dot(a, V)| < 0.12`).
- **Boss or port** on a curved wall: a short `disc` along the outward normal, with a `faint` face ring (`circleOf`) when it faces you. This is where a sense line, igniter or plug lands. Make it at least as wide as the flange that lands on it; a flange that overhangs its boss floats at the rim.
- **Saddle pad.** A flat flange on a curved body leaves a gap at its rim (the sagitta, `R − √(R² − a²)`): an 11.6 flange on an 18.4 pump body stood 4 units off at the sides. Wherever that gap would be more than about 0.15, seat the flange on a pad: a short cylinder whose base follows the host surface (`G.saddleOf(F, a, start, h, P)` with `start(angle)` found from the host's shape, the Raptor's `padOn`/`boss(..., { host })`). Record it as a disc `cut` by its host and marked `seated`, so the audit sees its true shape. Keep the pad inside one band of the host (an overhang under a step is drawn in front of the band that hides it; a Raptor pad lowered until its rim touched the step below became an interlock in every frame) and off the host's silhouette: a pad that straddles the silhouette is half in front of and half behind its host, and no painter's order draws it. The Raptor's LOX duct moved from 130° to 150° for that reason. **A saddle belongs to its host's assembly.** Its base is cut to the host's curve, so if it flies out with the pipe the cut base is on show apart: the Raptor's hot-gas pad, cut to the methane pump's turbine and ring, became a jagged shard with a pointed tail. Give the pad to the host's piece (`boss("ftp", …, { host: FTP_TURBINE })`) and let the moving pipe's flange leave its flat top; the audit's `seats` lists every seated shape whose host moves with another assembly.
- **Igniter plug, one terminal.** A plug carries either the igniter line (leaving its outer face along its axis) or the exciter cable, never both. The cable lands on its own small boss beside it.
- **Valve on a pipe:** a bulged `lathe` profile on `frameAlong(point, tangent)` turned with `smooth: true`, with bolt dots on both end faces, a round neck and an actuator box with a lid outline and two screws. It sits on a straight leg (body length plus both fillet cuts), the pipe is gapped under its body, and the actuator stands perpendicular to the pipe: up on a level pipe, sideways (a round can) on a vertical one, never along the pipe.
- **Volute:** `spiral(F, s, r0, r1, a0, a1)` fed to `tubePieces` with a radius that grows along it. Keep its centreline at housing radius plus tube radius plus 0.3, so it wraps the housing tangentially; a volute sunk into the housing is a pipe through a solid.
- **Ball joints and tees** are spheres (`sphereOf`): a rod-end bearing on an actuator, a cast tee where a branch meets a manifold.
- **Knurls, ribs and fins on a tilted axis:** `ribsOf`. Vanes on a face: `spokesOf` with a `sweep`.

### Pipes
- Lay out a route as a few straight legs between real ports, then `fillet` it with a bend of about 2.6 × the pipe radius. Pipes bend; they don't kink.
- End every pipe on a port with a flange, or on a block with a terminal dot. A pipe that stops in the air is a line that stops in the air.
- Add the parts a real line has: bands where it is clamped or welded (`bands: [0.5]`), a bellows where it must flex (a run of thin `disc`s, `bellows: [from, to, count]`), and a valve where flow is controlled. These are the Raptor `pipe()` helper's options (`kit.md`, section 11).
- Use few sizes, all big enough to tell apart: main ducts (r 5–8), feed lines (r 2–4), sense lines (r 0.9–1.5), cables (r 0.5–0.65).

### Choosing waypoints
Route the big lines first (ducts, downcomers, manifolds), then feed lines, then sense lines, then cables, so each smaller line routes round what is already there. For each route:
- **Start on the port's normal.** The first leg leaves the port straight out along its surface normal, long enough for the flange, then the bend: `fillet` takes at most 0.48 of the shorter leg, so a leg shorter than about twice the bend radius gives a tight bend, and a turn right at a port facing the viewer pinches ("Clearance and crossings", step 5).
- **Follow structure.** Legs run parallel to the body they follow (along a chamber, up a downcomer) or round it on a ring at a fixed radius (`arcPoints`), offset from its skin by the body's own clearance: skin radius plus about 1.5 × the line's radius. The owner's close-up showed sense lines fanning across the chamber's face and over the main duct to reach their block; on the new build the long line from the nozzle manifold runs up the left side, the fuel-valve cable runs up beside the downcomer, and the harness comes down from the controller in parallel drops into clamped lanes.
- **Stay on your side of the subject.** A line that serves a port on the far side goes round the back or round the side at a ring, never diagonally across the front of the thing being explained.
- **Prefer legs across the screen.** A leg that runs straight toward or away from the viewer projects short and makes every turn at its ends a cusp or a V ("Clearance and crossings", steps 4 and 5).
- **Leave room for fittings.** A valve, bellows or clamp needs a straight leg at least its body plus both bend allowances (`r_bend · tan(turn / 2)` each). On a bend the straight body cuts into the pipe; the Raptor's downcomer was rerouted so its valve sits on a straight run, and the oxygen duct gained a vertical straight for its valve.
- **Don't land on a rod.** A pipe's last leg must not run along a rod, tie rod, post or rail on screen, and its end fitting must not sit on one's outline: the blind-test bench's hose ran its last leg parallel to a tie rod, 2 px above it, so the rod read as plugged into the hose fitting. Approach the port across the rods, or turn the elbow so the leg lies across the screen. The audit's `crowding` treats every slender solid (length over 4× its radius or half-width) as a line, so it fails these. Check every pose too: a ferrule that sits on a moving plate's edge at the end of the stroke reads as touching it.
- **Cross deliberately.** If two lines must cross on screen, they cross once, on straights, at a clear angle, and far enough apart in depth that the clearance check passes with room to spare. A crossing near a bend or an end reads as a joint ("Clearance and crossings", step 6).
- **Split, don't overlap.** Where something wraps a pipe (flange, band, bellows disc, clamp, valve body), the pipe is gapped there and a piece ends on each face (`tubePieces` `gaps`, `crossingsOf` for clamps). Run through it as one chunk, the pipe is both behind and in front of the collar and no key can order it. The Raptor chamber jacket was split at its two rings for the same reason.

### Contacts and junctions
Every place two parts meet is either a flat face on a flat face, a tangent contact, or a rim landing on a round body. Nothing is sunk into anything: a sunk part is both in front of and behind its host, and no painter's order can draw it.
- **Flush.** A rod ends on the face of its clevis; a boss starts on the surface it stands on; a lug is a short pin from the housing to the ball.
- **Rim on a round body.** A cylinder of radius `a` meeting a sphere or tube of radius `R` ends where its rim lies on the surface, `sqrt(R² − a²)` from the centre. Its end face is then hidden inside the host and nothing shows through. This is how the Raptor's actuator rods meet their ball joints and how the manifold tube and the downcomer meet their tee.
- **Tees.** A branch into a pipe is a sphere fitting a little larger than both: the run is gapped where its rim meets the sphere and the branch ends on the sphere. Size the sphere so neither pipe's rim pokes into the other: for a branch of radius `b` into a run of radius `a`, `R ≥ sqrt(a² + b²)`.
- **Stand-offs.** A ring that would have to sink into a curved body to touch it (a manifold round a bell) stands clear by at least half its tube radius on small radial pads.
- **Valves and bellows on straights.** A straight lathe on a curved pipe cuts into it at both ends. Put them where the route is straight for their whole length.
- **Two flanges, one joint.** Where a duct bolts to a pump outlet flange, the duct's own flange meets the outlet flange face to face; two flanges in the same place interlock. Each part then keeps a flange when the figure comes apart.
- **Branches have fittings.** A branch leaves its run from a tee (a ball the run is gapped under, the branch starting where its rim meets the ball) or from a saddle pad on the run, never from a bare spot on the run's skin.
- **Struts end in ball joints.** A strut's eye disc laid along an oblique axis sinks into whatever it meets. Give each end a short stem along the host's normal, a ball on the stem, and the rod between the balls (rim on sphere at all four joints).
- **Balls with flats for joints that move.** In a 3D figure a rim landing on a sphere can't be ordered at every angle. Give the ball a flat (`T.ball({ flats: [{ n, d }] })`, recorded as `A.ball(c, r, { flats })`) and land the rim on the flat, or use coaxial knuckle discs. `references/3d.md`, section 5.
- **Everything is held.** Every solid is connected through contacts to the stand: brackets carry boxes, studs carry harness clamps, a gearbox plate joins a motor to its actuator. A clamp that touches only the cable it holds floats.

### Clearance and crossings
A pipe keeps about 1.5 radii between its centreline and anything but its own connection, so a 0.6 cable passes 0.3 clear of a duct's skin and a duct keeps half its radius off its neighbours. Where two lines must cross on screen, they cross in depth, one well in front of the other, and the chunk keys order them; where a pipe passes through a collar, clamp or valve it is gapped there (`kit.md`, section 11). Prove it rather than eyeballing it:
1. Record a shape for every item and every tube as you build, into one `A.recorder(P, { order, spring })` (`kit.md`, section 12): `R.put` for drawn items with their `shapes`, `R.solid` for each shape, `R.route` for each pipe and cable. Make the helpers do it, so nothing escapes. There is a recorded shape for everything the kit draws: `A.slab`, `A.extruded` and `A.cylinder` for floor prisms, `A.solid`, `A.lathe` and `A.disc` for turned parts (traced along the same curve the drawing uses), `A.prism` for `prismOf`, `A.ball`, and tubes.
2. Settle the painter's keys with `A.settle(R, P)` on every build: it measures the overlaps in every state the figure rests in (assembled and apart) and in every frame of its real explode (`explodeStates` replays the live springs and stagger) and runs `orderKeys`.
3. End the build with `A.auditOrExit(R, P)` and run `node build.mjs --audit` until every failing line prints zero. An interlocked pair is a geometry bug (a split, a gap or a tangent contact fixes it); don't bias it away.
4. Lines don't double back on screen. A horizontal leg that runs away from the viewer reads as going up, so a pipe that runs away and then drops reads as a cusp, a pipe ending in mid-air. Route the leg sideways on screen first (the audit's `folds` finds every case: a screen turn over 140° within 4 radii while the real turn is under 110°).
5. No outline folds back on itself, and no bend pinches to a point on screen. A leg that points at the viewer and then turns (a sense line leaving a jacket boss radially and climbing to a block, a pigtail leaving a transducer tip and hooking back) projects its fillet edge-on, and the two legs meet in a V with no visible radius. `folds` measures the screen radius of every visible bend over 90° and fails it when it is under twice the tube's screen radius. Fixes, in order of preference: let the turn happen inside a fitting (a ball tee on the boss, the line leaving it toward its next point: the Raptor's `elbow`, used by sense0, sense2, sense5 and the fuel-rich igniter line); bend in a plane that faces the viewer (the Raptor's pigtails loop in the transducer's own radial plane, which faces the camera; its local helper `sideOn(d) = unit3(cross3(unit3(V − d·(V·d)), d))`, with `V = G.viewOf(P)`, gives the direction across the screen from a leg along `d`, so a bend in the plane of `d` and `sideOn(d)` faces the viewer); move the port to where its normal lies across the screen. A bend in a plane whose normal makes `f = |n·V|` with the view projects to a screen radius of about `R·f²`; once that is under the tube's screen half-width, the inner edge of a single chunk runs backwards and the outline crosses itself in a little knot (the audit's `selfOverlaps`). `fillet(…, { P, tube })` raises each corner's radius to clear that, but only as far as the legs allow, so give a turn next to a port at least `R + 3r` of straight leg. A port that faces the viewer squarely (the Raptor's speed pickup on the methane pump) makes every approach a hairpin: move it.
6. Crossings happen on straights, clearly. Where one line's bend or end lands on another's outline on screen (touching, or overlapping by any amount), the eye reads a joint or a line threaded through another, whatever the true depth: a pigtail hook that peeks out from behind a sense-line elbow, a flange that lands on another line's band in the apart pose, a cable that runs along a short duct on screen and over its end flange. A fitting counts too: a harness clamp whose outline lands on another line reads as that line growing out of the clamp, as the Raptor's clamp did on the oxygen-to-preburner line apart. The audit's `crowding` lists every visible case, assembled and apart; reroute one line so the meeting is a clear crossing of two straights or a visible gap of at least a pixel (two for a clamp). Excuse a pair only after you have looked at it at 8× (`CROWD_OK`).

The first Raptor had 54 clearance problems (39 pass-throughs) and 323 depth-order problems; the routes in this section took both to zero.

### Shading and textures on curved surfaces
- **Smooth shading** (automatic on any profile with a smooth knot; `smooth: { slope }` or `{ slope, radius }` for an analytic curve; `bandsOf` for a body in bands; `sphereOf` for balls) on every curved surface: bells, throats, domes, cones, valve bulges, balls. A flat facet tone per row is only right on a cylinder. Each tone is one polygon over the whole body for as long as its region count holds, so no row seams show.
- **A texture ends on an edge or fades, along the axis too.** Each rib's `s0` and `s1` land exactly on a drawn step, crease or seam arc at the same `s`: pass `seams: true` and `ribsOf` returns the two seam arcs with the ribs, so they can't drift apart (the blind-test motor ran its ribs from 26 to 88 with seams at 24 and 90, and every rib stopped 2 units short at both ends). Where a rib row has no seam, let it run onto the face that ends the band (`s0`, `s1` equal to the band's ends) or fade it.
- **A texture on a pipe follows its cross-section.** Braid, corrugation, tape wrap and armour come from `tubePieces(route, r, P, { rings: { pitch, twist, cross, fade } })`: rings (or with `twist`, helices, and with `cross: true`, a braid of both hands) placed by 3D arc length along the whole route, each drawn as the visible front arc of its cross-section, fading toward both limbs and ending on the ferrule and gap faces. Never a `stroke-dasharray` along a chunk's centreline: it ends in straight hard edges along the hose, doesn't wrap the round section, piles up on the inside of bends, and restarts at every chunk joint.
- **Textures end on an edge or fade.** Ribs and bolt circles that are limited to the side facing you stop wherever the cut-off falls, in the middle of the surface: the bolt circles on the Raptor's chamber rings did, in the owner's close-up. Use `ribsOf(..., { fade })` with `fadedSvg`, and `dotsOf(..., { fade })`, so they fade out toward the turn (the Raptor uses `fade: [0.04, 0.42]` for bolt dots and `[0.1, 0.55]` for ribs). Seam arcs and band lines already end on the silhouette. A single texture line that has to stop in the middle of a face (a groove that runs out, a weld bead short of a corner) is drawn with `fadeLineSvg(points, { fade: [start, end] })`, which ramps it out over a few px with a gradient along the line; never let it end on a bare cut. Choose the cut-off from the way the feature faces, not from the ring it sits on: spray holes on top of a deluge ring face up and are seen all the way round, so they take `all: true`. Culled by the ring's outward normal (`least: -0.2`), the Raptor's row stopped at full strength just past the ring's sides while the ring ran on round the back, bare. Never cut a row on an up-facing feature with a radial `least`; if part of it really is hidden, `fade` toward the turn.
- **Smooth tones taper.** Where a tone band appears or disappears along a curved profile, it is born at zero width and grows (`smooth` refines rows by screen error until each boundary is within 0.35 px and every change of band count happens in a sliver). A band that ends on a straight cut across the surface, or a pale wedge of bare fill, is the bug the reviewer finds first.
- **Shape and shading describe one curve.** Smooth runs of a profile are interpolated with a monotone cubic Hermite curve (Fritsch–Carlson), and the kit uses that same curve for the normals, the tone sheets, the silhouette and the hull. A natural spline overshoots near a peak: on the Raptor's valves it put three sign changes of slope into a body that rises and falls once, and the tone bands zig-zagged across the bulge like wood grain while the outline stayed a polyline with a nub at every profile point. A monotone curve can only turn where the profile turns.
- **The monotone Hermite is only C1.** Its `dr/ds` is a piecewise quadratic: with hand-typed knots it plateaus at each knot and overshoots between them, and tone bands, which follow the slope, stall and then lurch, a staircase in scallops. The Raptor throat had extrema of slope at five places on a profile that bends one way; its deck cone wobbled more mildly. Any curved body larger than a fitting needs an analytic profile `r(s)` and its derivative passed as `smooth: { slope }`, sampled at 20–30 knots: the throat is `√(24.2² + (c·(s − 146))²)`, the cone `48·(1 − x)^0.8` closed to `r = 0` at its tip with a vertical tangent, so the outline rounds over the tip instead of peaking above a flat tip disc. The audit's `wobble` fails any profile smoothed without a `slope` whose `dr/ds` turns between its ends.
- **Outlines are ellipses, not facet polygons.** Facet counts are for tone fills only (about 3 px per facet on screen, `stepsFor(r, 32, 160, scale)`); every silhouette, rim and crease is sampled from the analytic circle at about 1.5 px, and creases and bevels end exactly where the faces turn away. A 20-sided flange outline next to a 96-sample torus reads as a notch at 4×.
- **Tube stripes.** Shine and shade bands run continuously through chunk joints and on to the very end: at every capped end they follow the outward half of the end ellipse, and at an end face turned toward you they stop on its inner rim, which is drawn. At a joint the stripes overhang the body by a further 0.5 (bodies overlap by 0.5, stripes by 1), and every frame is computed from the whole route, so the later chunk's stripe covers its own body's anti-aliased cut and ends on an identical stripe. With body and stripe cut on one line, a hairline crossed the volute's shade band at every joint, light in light theme and dark in dark. Check them at 4× at every bend, tee, bellows and flange, and on a strongly curved tapered tube. Anything you draw on a chunk yourself (a texture, flow marks) follows the same rule: compute it from the whole route, phase it by 3D arc length from the route's start, and extend it over the chunk's stripe overhang (1 unit past each joint), or the joint shows as a half-dash, a doubled mark or a notch. The `rings` option does this for you.

### Harness routing
Cables are the easiest way to ruin a figure: a cable drawn across the subject's face reads as a scribble. Route them like a real harness:
- **Lanes.** Run cables in parallel lanes on a ring around the body (`arcPoints` at a fixed `s`, each lane offset 1.4 units out and 0.2 along for r 0.62 cables). They leave the lane only to branch to their plug.
- **Enter in order.** Cables come down from the connectors in parallel drops and join the ring where their drop meets their lane (at `acos(x / laneR)`), so the innermost lane is fed by the connector nearest the front and no drop crosses another.
- **Leave in order.** The outermost lane leaves the ring first; an inner cable that turns outward while outer lanes still run beside it has to cross them.
- **Follow structure.** A cable that has to climb runs up beside a pipe (the Raptor's valve cable runs up the downcomer) instead of crossing open space in front of the engine.
- **Short branches.** Go from the lane straight to the port. Don't run diagonally across the subject's face.
- **Clamps** (`disc(-0.9, 0.9, r, frameAlong(point, tangent))`, tone `mid`) hold the lanes to the body at regular angles. Gap each cable where it passes through a clamp (`crossingsOf`), or the clamp and the cable interlock.
- **Ends land.** Each cable starts at a connector on the controller or a junction box and ends on a plug, sensor tip, valve lid or bulkhead connector, never on a clamp or in the air beside the bundle. An end that stays with its own assembly gets a terminal dot; an end on another assembly gets a connector shell (`cable(..., { shells: [false, true] })`) that moves with the cable.
- **Count what you draw.** Push every route into a list (`CABLES`) and build the readout from its length. The first Raptor readout claimed 8 cables while 11 were drawn.

## 9. Exploded views
An exploded view earns its place when the parts are the explanation (what feeds what, in what order):
- Choose the assemblies the real thing comes apart into (the Raptor has 11), not single solids.
- Move each assembly along its real disassembly direction, far enough that its silhouette clears its neighbours: down along the axis for stacked stages, out radially for side units.
- Every part keeps a home. Parts that bridge two assemblies (actuators, cables) go with the one they stay attached to, and when apart they still end on something of their own: a flange, a coupling nut at a quick-disconnect, a connector shell on a cable. Strut ends are ball studs on their hosts with the rod leaving with its assembly, and saddle pads stay on their hosts. Mark each end that comes off its mount when apart (`free: [true, false]`); the audit's `looseEnds` fails every other end that touches nothing of another owner in the apart state, and lists the free ones so each is a choice.
- Highlight and motion are separate. A sensor bolted to the chamber highlights with the plumbing but rides the chamber's spring (`ride`, emitted as `data-ride`). Moved with another assembly's vector on its own spring, it slides along its mount and sinks into it mid-flight.
- Choose explode vectors and the stagger so nothing passes through anything, in any frame: `references/motion.md` ("Order the stagger so nothing has to pass through") has the rules, and the audit replays every frame of the real springs, opening, closing and reversing halfway, and fails on any pass-through.
- List the assemblies in the legend as numbered buttons (`01 Gimbal and thrust puck`…) that select, the same as hovering does.
- Springs, staggering and per-frame depth re-sorting are in `references/motion.md`.

## 10. Screens and things on faces
A screen, a dial face or a display is drawn in the plane of the face it sits on (`examples/desk-computer/`):
- **The face plane.** `sideMatrix([0, y], 0, P, "left")` maps a group's local `(x, −z)` coordinates onto the vertical face at `y`. Draw everything on the screen (the glow, scanlines, a mark, `<text>` and a cursor) in those flat coordinates inside that `<g transform>`.
- **A recess.** Clip the screen to its rounded outline with a `clipPath` in the same plane, and draw the recess's inner sides as quads between the face and the face moved `depth` back. Shade the sides by facing (tops `iso-top`, sides by shade). Draw the outline `lo` on the face and `faint` at the back.
- **Motion on the face** is a transform in face coordinates. The CRT power-off scales the picture about the screen centre to a line, then to a dot, with a beam line and a fading dot.
- **Text the machine displays** (a typed line, a counter's digits) is part of the object, not a label, so the one-label rule doesn't apply. Keep it in the screen's own type and size, and fit it: the cap height is about 0.55 of the window's height and the widest string you will ever show (`8.88`, the longest unit) must fit its width with a margin of about a tenth of the height. Clip it to the window like any screen. A blind-test flow meter set 5.6 px digits in a 12.8 × 8 window, so the leading digit crossed the window's left border and the digit tops ran into the unit label.
- **Content seen through an opening** (a sight glass, a gauge window, a recess) sits behind the face, so draw the opening's inner walls between the face and the back plane, shaded by facing, and clip the content to the opening in the face plane as well as its own. A strip of bare face inside the opening reads as a hole drawn on the surface. For a shader behind an opening, `webgl.md`, section 5.

## 11. Shaders
Some subjects can't be drawn with lines: fire, exhaust, water, caustics, real lamp light, steam. These go in a WebGL layer *inside* the drawing, in world space and in the same camera, between a back SVG and a front SVG. `references/webgl.md` has the layer stack, the camera contract, the craft rules (nothing pops, no hard cut-offs, tonemap and dither, blending per theme) and how to verify them. Everything in this page still applies to the SVG around the effect. Its section 8 covers the light theme, which ships: inks solved from the colour the paper should show, ink that grows with the light, a dark physical ground for every glow, recipes for hot metal, glass, sparks, flame, steam, plasma and Cherenkov light, and a no-WebGL fallback that matches the shader.

## references/kit.md

# Kit API

Everything is in `kit/iso-kit.ts`, with the same API in `kit/iso-kit.mjs`. World coordinates are `[x, y, z]`, with x and y on the floor and z up, in units of about 1 px. Screen coordinates are `[sx, sy]` in the SVG viewBox. Paths come back as SVG `d` strings, rounded to 0.1.

## Contents
1. Types
2. Camera
3. Solids
4. Lines on faces
5. Parts library
6. Moving things
7. SVG strings (framework-free)
8. Card and page
9. React components
10. Round parts on any axis (`kit/lathe.mjs`)
11. Pipes, tubes and cables (`kit/tube.mjs`)
12. The audit (`kit/audit.mjs`)
13. The WebGL layer (`kit/gl.mjs`)
14. Tools
15. Turning groups (`kit/turn.mjs`, `turn-build.mjs`, `turn-audit.mjs`)

## 1. Types
```ts
type Flat = [number, number];            // screen point
type Point3 = [number, number, number];  // world point
type Plane = [number, number];           // floor point (x, y)
interface Plan { x: number; y: number; w: number; d: number; r: number }   // rounded rectangle on the floor
interface Projection { origin: Flat; scale: number; azimuth?: number; elevation?: number }
interface SolidPaths { fill; outline; crease; top; shades: string[4]; bevel?; seamless? }
type Tone = "hi" | "mid" | "lo" | "faint";
type LineTone = Tone | "lit" | "red" | "green" | "blue";
```

## 2. Camera
| Function | What it does |
|---|---|
| `fitProjection(points: Point3[], width, height, { pad = 24, headroom = 0, azimuth = 45, elevation = 30 })` | A projection that centres and fits these points in the viewBox. Pass every extreme point, such as `boxCorners(plan, z0, z1)` and the tallest tip. |
| `iso(point, P)` | World to screen. |
| `depthOf(point, P)` | Larger means nearer the viewer. Sort ascending to paint back to front. |
| `towardViewer(P)` | The floor direction pointing at the viewer, `[cos a, sin a]`. Faces whose normal points this way are visible. |
| `screenRise(h, P)` | How many screen px a height of `h` covers. |
| `cameraOf(P)` | The raw sines and cosines. |

## 3. Solids
All solids are prisms: a floor outline extruded straight up.

| Function | What it does |
|---|---|
| `slabOf(plan, z, h, P, steps = 8, bevel = 0)` | A rounded box. `steps` is the points per corner (2–4 for small parts, 8–10 for large rounded ones). `bevel` (0.3–1.6) adds a highlight line inset on the top along the visible edges, running into the silhouette corners. |
| `cylinder(cx, cy, r, z, h, P, steps = 32, bevel = 0)` | A round post, puck, foot, knob or pad. Use 16–20 steps for small ones and 56–72 for big discs. |
| `extrude(ring: Plane[], z, h, P, { convex = true, bevel })` | Any floor outline. For concave outlines pass `convex: false` (the fill becomes faces rather than a hull). |
| `ringOf(plan, steps)` | A rounded-rectangle outline as points. |
| `roundedPlan(plan, steps)` | The same thing. |
| `circleRing(cx, cy, r, steps)` | A circle outline as points. |
| `insetPlan(plan, by)` | Shrinks a plan (use a negative `by` to grow it). Keeps the radius sensible. |
| `corners(plan, inset)` | Four floor points inset from the corners, for feet and screws. |
| `boxCorners(plan, z0, z1)` | The eight corners, for `fitProjection`. |
| `haloOf(plan, z, h, P)` | A fill path for a soft shadow: `<path class="iso-halo" d=…>`. |

What a `SolidPaths` contains:
- `fill`: paper-coloured, it hides whatever is behind.
- `shades[0..3]`: visible sides, bucketed by how they face the light.
- `top`: the top face, lighter.
- `crease`: where top meets the visible sides.
- `bevel`: optional.
- `outline`: the silhouette.

`solidSvg` and `<Solid>` paint them in that order.

## 4. Lines on faces
| Function | What it draws |
|---|---|
| `onTop(points: Plane[], z, P, closed?)` | A polyline on a horizontal face. |
| `lineOnTop(a, b, z, P)` | One segment on a horizontal face. |
| `planOutline(plan, z, P, steps)` | A rounded-rectangle outline on a face, used for grooves and inlays (`insetPlan`). |
| `ring(cx, cy, r, z, P, steps)` | A circle on a face, used for screw rings, bezels and seats. |
| `segment(a, b, P)` | Any 3D segment. |
| `rise(at, z0, z1, P)` | A vertical segment. |
| `pointsAt(points, P)` | Projects a list of points, for screw and rivet dots. |
| `dotGrid(x, y, cols, rows, gap, z, P)` | Projected perforations. |
| `topMatrix(at, z, P, along = "x")` | A transform that lays text flat on a top face, for `faceTextSvg`/`FaceText`. |
| `sideMatrix(at, z, P, "left" \| "right")` | The same, on a vertical face. |

## 5. Parts library
| Function | Returns |
|---|---|
| `knurl(cx, cy, r, z0, z1, count, P, inset = 0.4)` | Vertical lines on the visible half of a cylinder. |
| `sideArc(cx, cy, r, z, P, steps)` | The visible front half of a horizontal circle on a cylinder side: seams and bands. |
| `sideSeam(plan, z, P, steps = 8)` | The same for a box: the visible near half of the rounded plan at height `z`, wrapping its corners and ending exactly on the slab's vertical silhouette edges. Pass the slab's own `steps`, and draw it as the slab's `inner`. It is the way to draw a weld bead, a band, a lid seam or a trim line on a box: never a `segment` inset from the corners. |
| `ringSeam(ring, z, P)` | `sideSeam` for any floor outline (the ring you gave `extrude`). |
| `sideRing(center: Point3, r, "x" \| "y", P)` | A circle on a vertical face: bores, rod ends, lenses. |
| `coil(from, to, [cy, cz], r, turns, "x" \| "y", P)` | `{ back, front }` spring halves, so you can paint back → rod → front. |
| `topTicks(from, to, step, every, edge, z, [minor, major], P, axis)` | `{ minor, major }` ruler ticks on a top face, growing from the line `edge` in +y (or +x). |
| `sideTicks(from, to, step, every, face, top, [minor, major], P, axis)` | Ticks hanging down a vertical face. |
| `radialTicks(cx, cy, r, count, every, z, [minor, major], P, from = 0, span = 2π)` | Dial and turntable ticks. Angle 0 points to −y, increasing clockwise as seen from above. |

## 6. Moving things
| Function | What it does |
|---|---|
| `translateAlong(P, [dx, dy, dz])` | A `"translate(x y)"` string that moves a pre-built group by a world offset. Use it for carriages, drawers, plungers and lifts. |
| `holdStill(element, [dx, dy])` | Gives every `[data-hold]` element inside `element` the opposite translate, so a clip stays where it is while the group it sits in moves by `[dx, dy]` screen units. Call it with the same offset whenever you move an item that holds a `G.rodOut` clip. |
| `axisVector(P, [1, 0, 0])` | The screen vector of one world unit. Use it to turn pointer movement into world movement. |

Rotations about z can't be done with a screen transform: rebuild the rotating paths for each frame or step. That is cheap for a needle, a pointer or a small part.

## 7. SVG strings (framework-free)
| Function | Notes |
|---|---|
| `solidSvg(paths, { tone = "hi", crease = "faint", halo, lit, flat, className, inner })` | When `paths.seamless` is set (smooth lathe shading, `sphereOf`), the group gets `data-seamless` and `ISO_CSS` strokes each tone region in its own colour, 0.4 wide, so the many abutting tone sheets show no hairline seams. `inner` is extra line markup placed just before the outline (`iso-edge`), for seams, weld lines and band lines that must sit under the silhouette stroke; the React `Solid` takes it as a prop. |
| `lineSvg(d, { tone = "lo", dotted, dashed, flow, className, free })` | `flow` animates dashes; use it for things moving along a path. `free: true` (or `"start"`, `"end"`) writes `data-free`, telling `scripts/lines.mjs` that an end is meant to stop on a bare face: rule lines that stand for printed text, a needle's tail, a pointer's tip. Every other line end must land on something. |
| `dotsSvg(points, { size = 0.5, tone = "mid", pulse })` | A point may carry a third value, its opacity (`[x, y, 0.4]`), which is how `dotsOf(..., { fade })` fades a bolt circle out instead of stopping it. |
| `fadedSvg(list, style)` | Draws a set of lines that each carry an opacity, `[{ d, alpha }]` or `[d, alpha]`, as returned by `ribsOf(..., { fade })`. Lines are grouped by opacity in steps of 0.05, each group in one `<g opacity>`, and lines under 0.02 are dropped. Use it for any texture whose members fade one by one toward a limb: ribs, fins, knurls, rule lines. |
| `fadeLineSvg(points, { fade = [start, end], tone = "lo", className, stops = 6 })` | One line (screen points) that fades out over its first `start` and last `end` px instead of stopping (each ramp carries `data-free` on its faded end, so the line check passes it). Each ramp is stroked with a `userSpaceOnUse` linear gradient along its own chord (a smoothstep in `stops` steps, the colour taken from `var(--anatomy-<tone>)`, so both themes work), and the middle is a plain `lineSvg`, so there are no overlapping caps, beads or seams. Use it for a texture line that ends in the middle of a face: a groove that runs out, a weld line that stops short of a corner, a rule on a curved plate. Keep a ramp shorter than a quarter turn of the line, since a linear gradient follows the chord. Gradient ids are a hash of the path, so the same line drawn twice shares one. |
| `wireSvg(from, to, { via = [], tone = "lo", dotted, dashed, flow, ends: "both" \| "from" \| "to" \| "none", size = 1.2 })` | A connector with terminal dots. |
| `faceTextSvg(transform, text, { size = 3, tone = "mid", anchor })` | |
| `groupSvg(children, { class, transform, "data-part": … })` | |
| `figureSvg({ width, height, label, body, standalone: false \| "dark" \| "light", className })` | The `<svg>`. `label` is the accessible description: say what the object is and what it shows. `className` is added to its class, which helps when you stack it under a canvas (`rt-back`, `rp-back`). |

## 8. Card and page
| Function | Notes |
|---|---|
| `plateHtml({ fig, title, hint, readout, keys: [{ mark, label }], caption, body })` | The card. `mark` is one of `raised`, `flat`, `lit`, `edge`, `dim`, `dotted`, `dashed`, `red`, `green`, `blue`. The readout element carries `data-readout`, so live code writes to it. |
| `pageHtml({ title, theme = "dark", body, script, width = 650 })` | A standalone document with `ISO_CSS` inlined. |
| `kitScript()` | From `scripts/inline-kit.mjs`. Prepend it to `script` so the page can call kit functions such as `lineOnTop` and `translateAlong` while animating. |
| `glScript()` | From `scripts/inline-kit.mjs`. The source of `kit/gl.mjs` (`GLSL_ISO`, `glCamera`, `glLayer`, `smooth`, `burst`, `settle`), to inline after `kitScript()` on a page with a shader: `script: kitScript() + glScript() + live`. It doesn't depend on the kit, so a page whose live code calls no kit function can inline it alone (the Raptor uses `glScript() + LIVE`). Everything is one module script, so live code must not redeclare `smooth`, `burst` or `settle`: a second `const smooth` is a SyntaxError that kills the page. |
| `turnScript()` | From `scripts/inline-kit.mjs`. The 3D runtime (`TURN`, `TURN_CSS`) with `export` stripped, for a page with live parts: `script: turnScript() + glScript() + live`. Its internals are prefixed `t3`/`T3_`, so it inlines beside the kit and the WebGL layer. |
| `ISO_CSS` | All the styles. `kit/iso.css` is the same text. |

## 9. React components
From `kit/react/draw.tsx`, which is server-safe:

- `<Solid paths tone crease halo lit flat className inner />` (`inner` is line markup placed under the outline, as for `solidSvg`)
- `<Line d tone dotted dashed flow free />`
- `<Wire from to via tone dotted dashed flow ends size />`
- `<Dots points size tone pulse />`
- `<FaceText transform size tone anchor>text</FaceText>`
- `<IsoFigure width height label>` …svg children… `</IsoFigure>`
- `<Plate fig title hint readout keys caption>` …figure… `</Plate>`

From `kit/react/live.tsx`, which is `"use client"`:

- `useReadout()` returns `{ anchor, say }`. Put `anchor` on your root element; `say(text)` writes the card's readout.
- `useLoop(tick: (dt, calm) => keepGoing, running)` runs a frame loop while `running` and while `tick` returns true. It returns `kick()` to restart it.
- `follow(current, target, dt, seconds, calm)` is exponential easing.
- `spring(state, target, dt, stiffness, damping, calm)` is a sub-stepped spring. It returns true when settled.
- `useEased(target, seconds)` returns an eased number as React state. Fine for small figures.
- `useInView(ref)`, `useStillness()`, `asksForStillness()`.
- `Stage` is the wrapper `IsoFigure` uses. It adds `data-inview` for CSS animations.

## 10. Round parts on any axis (`kit/lathe.mjs`)
`kit/iso-kit.mjs` builds prisms that stand on the floor. `kit/lathe.mjs` turns a profile around **any** axis, for bells, chambers, flanges, valve bodies, pump volutes, pipe fittings, actuators lying at an angle, and connectors on a vertical face. It returns the same `SolidPaths` as `slabOf`, so `solidSvg` paints them. Import it as `import * as G from "../../kit/lathe.mjs"`.

**Frames.** A frame is `{ o, a, u, v }`: an origin, the unit axis, and two unit radial directions. A point on the part is `(s, r, angle)`: `s` along the axis from `o`, `r` from the axis, and `angle` from `u` toward `v`.

| Function | What it does |
|---|---|
| `frameAlong(o, a)` | A frame on axis `a` through `o`, with `u` and `v` chosen for you. Use it for pipes, bosses and flanges at any angle. |
| `frameOf(o, a, u, v)` | A frame with explicit radial directions, so angles mean something: `G.frameOf([0, 0, z], [0, 0, 1], [1, 0, 0], [0, 1, 0])` makes angle 0 point along +x. |
| `pointOf(F, s, r, angle)` | The world point. |
| `radialOf(F, angle)`, `tangentOf(F, angle)`, `angleToward(F, direction)` | Directions in the frame, and the angle that faces a direction. |

**Solids.** A meridian is a list of `[s, r]` points along the axis. Add a third element (`[s, r, 1]`) to mark a smooth ring, so no crease line is drawn there. Use that for curved profiles such as a bell or a dome. A meridian with any smooth vertex is shaded smoothly on its own (`smooth` defaults to `"auto"`, which is `softOf(meridian)`); pass `smooth: false` to opt out, `true` or `{ rows }` to force it on a profile without smooth vertices, and `{ slope }` or `{ slope, radius }` for an analytic curve.

**Smooth shading.** Without it every facet row gets one flat tone, and a curved profile shows a staircase of tone blocks (the first Raptor bell and throat did, and the reviewer saw it at once). With `smooth` the side of each meridian segment is shaded from an interpolated normal: the tone boundaries become the true iso-light curves, sampled at `rows` levels and emitted as one polygon per tone region, so they run as clean curves across the whole part.
- `smooth: true` interpolates each run of smooth vertices (`[s, r, 1]`) with a monotone cubic Hermite curve (Fritsch–Carlson slopes: zero where the profile turns, a weighted harmonic mean elsewhere), and uses that one curve for the normals, the tone sheets, the silhouette and the hull, resampled until it is within 0.2 px of the chord. Shading and outline then describe the same shape, and the normal can only change sign where the profile does. Use it for any profile with smooth vertices: domes, throats, cones, valve bulges, a flame cone. (A natural spline overshoots near a peak; on a valve bulge it zig-zagged the tone bands across the body.)
- `smooth: { slope }` takes the normal from an analytic `dr/ds`, so a body cut into bands shares one normal field and the tone curves cross the band joints without a step. With a `slope` alone the profile points are joined straight, so sample the analytic `r(s)` densely (20–30 knots on a throat), and the silhouette of a waisted body uses the same `slope`, so the outline follows the true curve too.
- `smooth: { slope, radius }` also takes the surface from an analytic `r(s)`: the tone sheets, the hull and the silhouette all follow `radius` between the knots, so two knots are enough for a band. This is what `bandsOf` passes to every band.
- **Hand-typed knots are not a curve.** The monotone Hermite is only C1: its `dr/ds` is a piecewise quadratic that plateaus at every knot and overshoots between them, so tone bands stall and then lurch, a staircase in scallops. The Raptor throat (`[128, 31, 1], [134, 27.6, 1], …`) showed it at 5×. Any curved body larger than a fitting gets an analytic profile and `smooth: { slope }`: the throat is `r = √(24.2² + (c·(s − 146))²)`, the flame cone `r = 48·(1 − x)^0.8` closed to `r = 0` at the tip, the valve bulge an ellipse, the gimbal puck `18.4 − a·|s − 297.5|⁴`. The audit's `wobble` lists every profile smoothed without a `slope` whose `dr/ds` turns between its ends.
- `rows` (default 6) is the starting sampling along each segment. Rows are then split until every tone boundary is within `error` (0.35 px) of a straight chord, and wherever the number of bands changes down to `depth` (8) levels or to `fineness` (0.1 px) on screen, whichever comes first, so a band that appears or disappears is born at zero width in a sliver instead of ending on a straight cut. Each tone is then emitted as one polygon for as long as its own number of regions holds, across every meridian segment and smooth knot of the body, and only that tone is bridged where its count changes; a band that comes and goes no longer cuts the other tones into rows. That made a small turned ball about half the size and removed the row seams. Visibility uses the drawn facet's normal, so the tones always reach the outline; every tone polygon is wound the same way, so overlapping pieces of one tone never cancel into a hole.
- The result is marked `seamless` (see `solidSvg`). Flat caps (rings where `s` doesn't change) keep their facet shading.

| Function | What it does |
|---|---|
| `lathe(meridian, F, P, { steps = stepsFor(rMax, 32, 160, scaleOf(P)), fill = "auto", hole = null, bevel = 0, rims = "all", smooth = "auto", waist = 0.5 })` | The general solid of revolution. Facets are shaded in four tones by the kit's light and bucketed like `extrude`. `fill: "auto"` uses the hull of the rings unless the true silhouette lies more than `waist` px inside it (a throat, a spool), and then fills and outlines the body with `silhouettePoints` on both sides joined by the visible end arcs (only when the silhouette is unbroken along the whole side: a body whose surface faces you head-on somewhere, like a valve bulge that leaves its neck at a right angle, falls back to the hull, because joining the broken pieces drew stray triangles across it); `"surface"` forces that, `"hull"` forces the hull; `fill: "facets"` fills only the visible facets and draws no outline, which you need for a concave or open shape seen from inside. `hole: [s, r]` cuts a round hole into the fill and outline, for the end of a tube. `bevel` adds a highlight inset from a closed cap (a meridian end at `r = 0`). `rims` keeps the outline on the end rings: `"all"`, `"none"`, `"first"`, `"last"` or `"ends"`. Only hull edges along the first or last ring are dropped; edges along interior rings always stay, so the side silhouette stays whole. |
| `solidOf(profile, F, P, options)` | Closes a profile to the axis at both ends and picks `steps` from the largest radius. It is the usual way to make a turned part: `G.solidOf([[0, 12], [6, 12], [8, 9, 1], [20, 9]], F, P)`. |
| `bandOfProfile(profile, F, P, options)` | An open band with no caps, for one stretch of a long body that is split into depth-sorted pieces. Prefer `bandsOf`, which cuts the whole body at once. |
| `bandsOf(profile, cuts, F, P, { slope, radius, steps, rows = 6, rims = "none", fill = "auto", samples = 72 })` | One curved body (`s` rising along `profile`) cut at the `s` values in `cuts` into depth-sortable bands that share one normal field, one surface and one facet count. Pass the analytic `slope` and `radius`, or neither: then `profileCurve(profile)` makes them from the profile (the monotone Hermite through each run of smooth knots, straight elsewhere), so no band ever has its own end slopes. Returns `{ bands, sidesOf, radius, slope, steps, s0, s1 }`. Each band is `{ s0, s1, profile, paths, outline, seam }`: `paths` is ready for `solidSvg` with its outline cleared, `outline` is the band's own chord outline if you need it (the last band of the Raptor skirt keeps it), `seam` is the visible arc where it meets the next band (put it in `inner`), and `profile` is what to record for the audit (`A.solid(F, band.profile, { radius })`). `sidesOf(from = s0, to = s1, { ends = false \| true \| "first" \| "last", count = samples })` returns `{ d, points }`: the body's two analytic side edges over that span as one path, optionally with the visible rim arcs at its ends, plus their 3D points to record (`points.map((list) => A.tube(list, 0.4 / scale))`). Draw `d` as one item with a small depth bias over the bands, so the outline is one continuous curve. |
| `profileCurve(profile, { slope, radius })` | `{ s0, s1, radius(s), slope(s) }` for a profile whose `s` rises: the curve `smooth` draws through it. Throws if `s` doesn't rise, since a step in a profile is a crease and belongs to a separate solid. |
| `outlineOf(meridian, F, P, { steps, fill, hole, rims, smooth, waist })` | Only the `fill` and `outline` of `lathe`, without the shading, for a body whose outline you draw separately. |
| `softOf(meridian)` | True when any vertex is marked smooth. |
| `disc(s0, s1, r, F, P, options)` | A short cylinder on the axis: pucks, flanges, bosses, pins, plugs. |
| `ringBand(s0, s1, rIn, rOut, F, P, options)` | An annulus with thickness: a flange or collar around something. |
| `stepsFor(radius, least = 16, most = 112, scale = 0)` | A facet count. With `scale` (screen px per unit, `scaleOf(P)`) it keeps every facet about 3 px long on screen; `disc`, `ringBand`, `solidOf`, `bandOfProfile` and `lathe` use that (32–160) when you pass no `steps`. Facets only set the tone fills: outlines, rims, creases and bevels are sampled from the true circle at about 1.5 px and end exactly where the faces turn away. |
| `saddleOf(F, a, start, s1, P, { steps })` | A saddle pad: a cylinder of radius `a` along `F.a` from a curved host surface to a flat face at `s1`. `start(angle)` is where each generator leaves the host (find it by bisecting the host's `sdf`), or a number `R` for a host cylinder perpendicular to `F.a` (then `saddleStart(R, a, angle)` gives each generator's start). The fill is the union of its visible facets and face, the outline its two side generators, the visible part of the saddle curve and the face rim, so it meets the host along the true intersection. Seat flanges on it wherever a flat flange on a curved body would leave a rim gap. |
| `prismOf(F, polygon, s0, s1, P)` and `stadium(r0, r1, offset)` | A prism of any convex `[x, y]` polygon in the frame's `u`, `v` plane, extruded along `F.a`, with facet tones, the hull as fill and outline, and creases only at sharp corners. `stadium` is the hull of two circles, for a gearbox plate joining a motor to an actuator. |
| `sphereOf(centre, r, P, { steps = 48 })` | A ball with exact smooth tones: each tone region is the projected hull of a spherical cap, so it costs four small polygons instead of a turned profile. Use it for rod-end bearings, tee fittings and knobs. It is `seamless`. |
| `silhouettePoints(profile, F, P, { samples = 72, slope, radius })` | The two side edges of `silhouetteOf` as 3D point lists, to record the outline's shape for the audit. |
| `silhouetteOf(profile, F, P, { samples = 72, slope, radius })` | The two analytic side edges of the surface of revolution through `profile` (`[s, r]` points, linearly interpolated), as one path of two polylines: the points where the surface turns away from the viewer (`cos(θ − φ) = r′·aV/|V⊥|`). Use it to draw one continuous outline over a body built in bands. Where the surface faces the viewer head-on (`|c| > 1`) a sample is skipped. Pass the analytic `slope` (the same one the bands are shaded with) so the edge doesn't kink at every profile point. |

**Lines and dots on round surfaces.** These draw only what faces the viewer. `slope` is `dr/ds` of the surface at that ring, so a line on a cone or bell hides where the real surface turns away.

| Function | What it does |
|---|---|
| `arcOf(F, s, r, P, { slope = 0, steps, least = 0.02, inward = false, from = 0, to = 2π, trim = 0.3 })` | The visible arc of a ring on the surface: seams, bands, weld lines, a lip. The limb angles are solved exactly (`t = φ ± acos(c)`), and each end that stops at the limb is shortened by `trim` px so its round cap stays inside the outline stroke. `inward: true` draws the arc you see on an inner surface, such as the inside of a nozzle. `least: -1` gives the whole ring. Draw seams *under* the outline: `solidSvg(paths, { inner: lineSvg(arcOf(…), { tone: "faint" }) })`. Appended after the solid, a faint seam cap paints over the edge stroke and notches the silhouette at every seam. |
| `circleOf(F, s, r, P, steps)` | A full closed circle, such as a face ring, a bore or a void you fill. |
| `ribsOf(F, s0, s1, r, count, P, { phase, twist, least = 0.12, r1 = r, fade, seams })` | Visible axial lines: a knurl, ribs or fins along any axis. `twist` makes them helical and `r1` lets them run along a cone. With `fade: [lo, hi]` it returns `[{ d, alpha }]`, each rib's opacity a smoothstep of how much it faces you, so the set fades out toward the turn instead of stopping at `least`. Draw them with `fadedSvg`. `seams: true` (or `"first"`, `"last"`) adds the seam arcs at `s0` and `s1` to the result (at full opacity), so a rib row and the seams it ends on always share one `s`; leave it off only where the ribs end on a step or a band end that is already drawn. |
| `rodOut(F, from, to, r, P, { stroke, ...latheOptions })` | A rod that slides out of a gland at `s = from` (`F.a` points out of it), its tip at `to` at rest, travelling up to `stroke` further out, or the same rod retracting into it. Returns `{ paths, clip, from, to, stroke, reach }`: `paths` is one rod from `from − stroke` to `to`, and `clip` a path covering the rod from the gland face to `to + stroke`. Draw `<clipPath id><path data-hold d=clip/></clipPath><g clip-path=url(#id)>solidSvg(paths)</g>`, move the item, call `holdStill`, and record `A.slide(F, from, to, r)` (`craft.md`, section 3). |
| `spokesOf(F, s, r0, r1, count, P, { phase, sweep, from, to })` | Radial lines on a face, for vanes and turbine blades. |
| `dotsOf(F, s, r, count, P, { phase, slope, least = 0.08, all = false, fade })` | Screen points of a bolt circle on the visible side. Pass them to `dotsSvg`. Use `all: true` for a face you see head-on. A `least` well above 0 stops the circle in the middle of the surface; pass `fade: [0.04, 0.42]` instead and the dots carry a falling opacity. |
| `fadeOf(value, [lo, hi])` | The smoothstep both fades use. |
| `facingOf(F, angle, P, slope)` | How much a surface at that angle faces the viewer. Positive means visible. |

**Paths in 3D**, for pipes and cables.

| Function | What it does |
|---|---|
| `fillet(points, radius, perArc = 7, { P, tube, margin = 1.3 })` | Rounds every corner of a polyline with a bend of `radius`, clamped to the room available (0.48 of the shorter leg). Lay out a pipe as a few straight legs, then fillet it. With `P` and `tube` (the pipe radius) each corner's bend grows to `margin · tube / (0.7 · f²)`, where `f = |n · V|` is how squarely the bend's plane faces the viewer: a bend seen edge-on projects to a radius `R·f²`, and below the tube's own radius the outline folds back on itself. The legs still cap it, so a turn next to a port facing you needs long legs or an elbow fitting. |
| `resample(points, spacing)`, `pathLength(points)`, `cut(points, from, to)` | Even spacing, total length, and a sub-path by arc length. |
| `pointAlong(points, at)`, `tangentAlong(points, at)` | Position and direction at an arc length. Use them to place flanges, bands, valves and clamps on a route. |
| `spiral(F, s, r0, r1, a0, a1, steps = 28)` | 3D points of a widening spiral, such as a pump volute. Feed them to `tubePieces` with a radius function. |
| `arcPoints(F, s, r, a0, a1, steps = 24)` | 3D points around a ring, such as a manifold, a deluge ring or a harness lane. |

**Camera and vectors.**
- `viewOf(P)` is the unit direction to the viewer, so `dot3(p, viewOf(P))` equals `depthOf(p, P)`.
- `lightOf(P)` is the kit's light.
- `scaleOf(P)` is the screen units per world unit.
- `toneOf(normal, P)` and `bandOf(score)` give the four-tone bucket.
- `hullOf(points)` and `runsOf(flags)` are the hull and run helpers the module uses itself.
- The vector helpers are `dot3`, `add3`, `sub3`, `mul3`, `len3`, `unit3`, `cross3` and `lerp3`.

**A long curved body in bands.** Cut it with `bandsOf` so each band depth-sorts against the pipes that wrap it. Every band shares the body's `steps`, its normal field and its surface, so tone curves cross every joint without a step, and the outline comes from `sidesOf` as one curve instead of a chord per band that kinks at every joint. The Raptor bell:
```js
const BELL = G.bandsOf([[3.2, bell(3.2)], [116, bell(116)]], [14, 28, 44, 60, 76, 92, 106], ENGINE, P, { radius: bell, slope: bellSlope, steps: 96, rows: 8 });
BELL.bands.forEach((band, index) => put("nozzle", ex(ENGINE, (band.s0 + band.s1) / 2), S(band.paths, { tone: "hi", inner: index === 2 ? Ln(band.seam, { tone: "faint" }) : "" }), { shapes: [record(A.solid(ENGINE, band.profile, { radius: bell }))] }));
const SIDES = BELL.sidesOf(3.2, 106, { ends: "first" });
put("nozzle", ex(ENGINE, 105), Ln(SIDES.d, { tone: "hi" }), { bias: 0.2, shapes: SIDES.points.map((points) => A.tube(points, 0.4 / KS)) });
```
Don't build bands by hand from separate `bandOfProfile` calls on two-knot chords with their own Hermite fits: each band then has its own end slopes, its tones step at every joint, and its chord outline kinks.

**Known traps.**
- **Four-tone quantisation follows the slope of each facet row** when a body isn't smoothed. Mark every curved knot smooth (`[s, r, 1]`) and the shading follows on its own; a profile of hard knots that still describes a curve needs `smooth: true`. Use `bandsOf` on a body cut into bands.
- **Smooth shading is heavier.** A small turned ball with `smooth` costs about 9 KB; `sphereOf` costs under 3 KB. Use `sphereOf` for balls and keep `rows` low on small parts.

## 11. Pipes, tubes and cables (`kit/tube.mjs`)
```js
import { tubePieces, tubeSvg, runsOf, crossingsOf } from "../../kit/tube.mjs";
```
| Function | What it does |
|---|---|
| `tubePieces(points3, radius, P, { maxLength = 30, spacing = 2.2, closed = false, breaks = [], gaps = [], caps = true, stripes = "auto" })` | Splits a 3D route into chunks of at most `maxLength` world units. Each one is drawn as a 2D ribbon with a paper body, a shine band toward the light, a shade band away from it, and two edge lines. Neighbouring pieces overlap by 0.5 units, and the edge lines are cut from the same overlapped span as the body, so neither a seam nor a notch in the outline shows at a joint. The shine and shade run 0.5 further than the body at every open end, so a stripe covers the anti-aliased cut of the body under it and its own cut lies on the neighbour's identical stripe; every frame normal is a central difference along the whole route, so the stripes of two chunks coincide exactly where they overlap (with both cut on one line, a hairline crossed the volute's shade band at every joint). `radius` is a number or a function of `t` (0 to 1 along the route), for tapers and volutes. `breaks` are arc lengths where a chunk must end (at a crossing, or where the pipe turns back past a part). `gaps` are `[from, to]` arc-length spans that are not drawn because a collar, clamp, valve or fitting covers them; the pipe ends flat on each side of a gap. Every end next to a gap gets an elliptical cap (the projected cross-section, with its outer half-arc as an edge), and so do the route's own two ends unless you pass `caps: false` (or `[start, end]`, such as `[false, true]` for a branch whose start hides inside a tee collar), so a pipe meets a flange face along the right curve instead of a straight cut. The shine and shade stripes follow the cap: onto the outward half of the ellipse when the end face is turned away, and up to its inner rim (drawn as an edge) when the face is turned toward you, so no stripe stops short of the end. A closed route with no gaps wraps its first and last pieces round each other. Returns `[{ mid, body, shine, shade, edges, wide, points, radii, ends, from, to }]`: `mid` is the chunk's centre in world space, to depth-sort it; `wide` is true when the route is wide enough on screen for its shine and shade bands (decided once for the whole route from its widest point, so a taper never drops its stripes at one chunk joint; `stripes: true` or `false` overrides it); `points` and `radii` are the chunk's 3D centreline and radii (record them for the audit); `ends` says which ends are real ends rather than joints with a neighbour. |
| `tubeSvg(piece, { tone = "hi" })` | One piece as `<g class="tb">`. Shine and shade appear only on `wide` pieces, then the piece's rings (`.tb-ring`), then the edges. |
| `tubePieces(…, { rings: { pitch = 3, twist = 0, cross = false, fade = [0.1, 0.55], phase = 0, steps = 16, tone = "lo" } })` | A texture that follows the pipe's cross-section: a braid, corrugation, tape wrap or armour. Stations sit every `pitch` units of 3D arc length from the route's start (`phase` shifts them), so the pattern is continuous through every chunk joint; each is drawn as the visible front arc of the cross-section there, split into short segments whose opacity is `fadeOf(facing, fade)`, so the texture fades toward both limbs instead of ending on a hard line. `twist` (in radii) turns each ring into a helix that advances that far from limb to limb, and `cross: true` adds the other hand, for a braid. Rings run over each chunk's stripe overhang, so the later chunk at a joint draws an identical piece over its own body's cut, and stop on real ends and gap faces (rings keep a third of a pitch off an end face, helices end on it). Each piece carries `rings: [{ d, alpha }]`; `ringsSvg(rings, tone)` draws them alone. |
| `runsOf(total, gaps)` | The drawn `[from, to]` spans of a route of length `total` once `gaps` are taken out. Record each run as its own tube for the audit. |
| `crossingsOf(points, centre, axis, radius)` | Arc lengths where a route crosses the disc of a clamp or collar (the plane through `centre` normal to `axis`, within `radius`). Turn each into a gap of the clamp's thickness. |

The fills for `.tb-body`, `.tb-shine` and `.tb-shade` are part of `ISO_CSS` (and `kit/iso.css`), so `tubeSvg` works on any kit page with no extra CSS. Override them in page CSS only to restyle tubes, as the Raptor does for selected parts (`.it[data-on] .tb-body`).

**Depth-sorting tubes.** A pipe runs between parts at many depths, so draw each piece at its own depth: put it in your item list with key `G.dot3(piece.mid, G.viewOf(P))`, next to the solids it passes. Use short pieces (`maxLength` 6–18) where a pipe weaves between neighbours, and long ones (400) for a ring that lies flat on the floor. To split a pipe between SVG layers, test `piece.mid`. The ripple tank's motor cable goes three ways: pieces over the bridge (`piece.mid[2] > Z.top - 1 && piece.mid[1] < IN.y + IN.d - 10`) to the front SVG before the bar, the rest above the rim to the front SVG after the near caps, and the run down the leg to the control box to the middle SVG. Its lamp cable sends the pieces that hang over the base edge (`piece.mid[1] < BASE.y - 0.2`) to the back SVG. In a figure as dense as the Raptor, don't tune these keys by eye: record the pieces and let `orderKeys` (section 12) settle them.

**Anything that wraps a pipe splits it.** A collar, band, bellows disc, clamp or valve body is wider than the pipe it sits on. If the pipe runs through it as one chunk, the chunk is both behind and in front of the collar and no key can order them: the collar either hides the pipe's near side or the pipe paints over the collar's face. Gap the pipe at the collar (`gaps: [[at - t/2, at + t/2]]`): the far piece ends at the collar's back face, the near piece starts at its front face, and both order cleanly. The Raptor's `pipe()` does this for its own end flanges, bands, bellows and valve, and `cable()` for the harness clamps via `crossingsOf`.

The Raptor's `pipe()` helper is a good pattern:
1. `fillet` the control points with a bend of 2.6 × r (at least 3). A valve or bellows needs a straight leg at least as long as its body plus both fillet cuts (`r_bend · tan(turn / 2)` each); put it on a bend and its ends cut into the pipe.
2. Work out the collars first (end flanges, bands, bellows discs, the valve body) and pass their spans as `gaps`.
3. `tubePieces` the route, recording each chunk with its `points` and `radii`, and each drawn run as a tube for the audit.
4. Draw the collars, owned by the pipe (`owner`), so the audit doesn't count a pipe's own flange as a collision.

`cable()` adds a terminal dot at an end that stays with its own assembly and a connector shell (`shells: [start, end]`) at an end that lands on another one, so every cable still ends on something when the figure comes apart. Both helpers take `free: [start, end]` for an end that comes off its mount when the figure is apart, and fillet with `{ P, tube: r }` (see `fillet`). A line that must leave a port facing the viewer starts from an `elbow` (a ball on the boss, rim on sphere at both joints, the line leaving it toward its next point), so the turn happens inside a fitting. `pipe()` puts each end flange's back face on the route end and can end a line in a `coupling` (a union nut at a quick-disconnect) instead of a flange.

## 12. The audit (`kit/audit.mjs`)
```js
import * as A from "../../kit/audit.mjs";
```
**The one-line hook.** Make a recorder, let every drawing helper put its items and record its shapes into it, settle the keys, and end the build with `auditOrExit`:
```js
const R = A.recorder(P, { order: STAGGER, spring: SPRING });
R.put({ piece: "base", at: centre, bias: 0.1, svg, shapes: [R.solid(A.slab(plan, 0, 6, { name: "base.plate" }))] });
R.route("base.cable", route, 0.6, { piece: "base", gaps, limp: true });
A.settle(R, P);
R.items.sort((a, b) => a.key - b.key);
A.auditOrExit(R, P, { crowd: [] });
```
`node build.mjs --audit` then prints the report and exits non-zero on any failing line; without the flag `auditOrExit` returns at once. A figure that doesn't come apart passes no `order` and every explode check reduces to the rest pose.

| Function | What it does |
|---|---|
| `recorder(P, { order = [], spring = {}, ground = grounded })` | `{ items, solids, tubes, routes, put, solid, route, nameFor }`. `put({ at, bias, key, name, piece, move, ride, shapes, ...rest })` adds a drawn item (its key is `dot3(at, V) + bias` unless you pass `key`; the rest, such as `svg`, `route` and `chunk`, is kept on the item) and stamps its shapes with the item's `move`, `ride` and `piece`. `solid(shape)` records a shape and returns it, for the item's `shapes` or for static parts drawn outside the sorted layer. `route(name, points, radius, { owner, touch, bundle, limp, gaps, closed, ends, rims, free, piece, move, ride })` records a pipe or cable: one tube per drawn run between `gaps`, with its `tips`, `tipAxes`, `tipRims` and `free` ends, plus the route for `folds` and `crowding`. `nameFor(base)` numbers repeated names (`base`, `base#2`, …). `order` and `spring` are the live page's stagger and spring. |
| `explodeStates(order, spring, { uniform = 10, stride = 4 })` | `{ depth, frames, apart }`: the depth-solver states (uniform shares, every `stride`th replayed frame with reversals at every stagger boundary, prefix states) and the clearance frames (every replayed frame plus the prefix states). `{ depth: [0], frames: [] }` when nothing comes apart. |
| `settle(R, P, { states, skip = sameRun, ...overlapOptions })` | Runs `overlapsOf` and `orderKeys` and writes the keys onto the items. It keeps the order the items had, so you may sort `R.items` afterwards. Run it on every build. |
| `audit(R, P, { allow, crowd, cover, ground, frames, apart })` | Every check below on the recorded shapes, as one result for `report` and `failures`: clearance at rest, apart and in every frame, solid clearance, crowding at rest and apart (against slender solids too), stand-in shapes, terminals, loose ends, folds, self-overlaps, wobble, seats, supports and depth order. `allow` excuses depth pairs, `crowd` crowding pairs, `cover` stand-in items. It never sees line markup: run `scripts/lines.mjs` on the page for that. |
| `auditOrExit(R, P, { flag = "--audit", argv, ...auditOptions })` | With the flag: `audit`, print the report and a summary line, and set `process.exitCode = 1` on any failure. |
| `sameRun(a, b)`, `grounded(shape)` | Neighbouring chunks of one route (skipped by the overlap pass), and the default ground (a shape with `ground: true` in its meta or a name starting `stand.`). |
| `keyAt(item, state, V)`, `labelOf(state)`, `cameraBasis(P)` | An item's sort key in an explode state (`key + lift · share`), a state's printable label, and the camera's screen-right, screen-down and toward-viewer unit vectors `{ R, D, V }`, for probes of your own. |

A detailed figure fails in ways the eye misses until a reviewer zooms in: a pipe or cable that passes through something, at rest or in any frame of the explode, a flange that floats off its mount, a part that hangs in the air or is sunk into its neighbour, a route that doubles back or pinches to a point on screen, a bend or end that lands on another line's outline, a cable end that dangles when the figure comes apart, and two things drawn in the wrong order where they overlap. The audit measures all of them from the real geometry. On the Raptor (478 items, 1,119 depth states, about 4,400 replayed and prefix frames for clearance) the keys take about a minute on every build and a full `--audit` about three minutes in all, most of it the per-frame clearance.

**Shapes.** Record a shape for everything you draw, in world units, as you build it:

| Constructor | Shape |
|---|---|
| `solid(F, profile, { radius, ...meta })` | A turned part: the profile closed to the axis at both ends, the same `[s, r]` list you give `solidOf`. Runs of smooth knots are traced along the same Hermite curve the drawing uses (or along `radius(s)`, as for a `bandsOf` band), so the recorded surface is the drawn one, not its chords. |
| `lathe(F, meridian, { radius, ...meta })` | The shape of `G.lathe(meridian, F, P)` with the same tracing: any closed meridian (rings, valve bodies, cans). |
| `body(F, polygon, meta)` | Any closed `[s, r]` polygon turned round `F`, taken as it is. Edges that lie on the axis are not surface, so a point deep inside a disc measures its true depth. |
| `disc(F, s0, s1, r, meta)`, `ring(F, s0, s1, rIn, rOut, meta)` | Flanges, bosses, pins, collars. |
| `box(min, max, meta)`, `orientedBox(centre, axes, half, meta)` | Square blocks; a beam at 45°. |
| `slab(plan, z, height, meta, steps = 8)`, `extruded(ring, z, height, meta)`, `cylinder(cx, cy, r, z, height, meta)` | The shapes of `slabOf`, `extrude` and `cylinder`: the same rounded plan or any polygon ring (convex or not) stood on the floor, so a rounded deck corner or an L-shaped bracket is checked as drawn. |
| `prism(F, polygon, s0, s1, meta)` | The shape of `G.prismOf`: any polygon in the frame's `u`, `v` plane extruded along `F.a`. |
| `traced(meridian, { radius, error = 0.02, depth = 6 })` | The polygon `solid` and `lathe` record. |
| `ball(at, r, meta)` | Spheres and terminal dots (`size / scale` world units). `meta.flats: [[nx, ny, nz, d]]` cuts flats (the half-space n·(p − c) ≤ d is kept): `sdf` is `max(\|p − c\| − r, n·(p − c) − d …)`, surface samples include the flats' rims, `boundsOf` is unchanged (conservative). The 3D mode's knuckle balls record this. |
| `tube(points, radius, meta)` | A pipe or cable centreline with a radius (number, function of 0..1, or a list per point). |
| `slide(F, from, to, r, meta)` | A rod sliding out of a gland at `from` (a `disc` from `from` to `to`). Posed in an explode or stroke state, it stretches from the gland face to `to + offset · F.a` instead of translating, so every check sees the rod that is drawn. Put it on the moving item (the one with the stroke `move`). |
| `moved(shape, offset)`, `sdf(shape, p)`, `boundsOf(shape)` | Helpers: translate, signed distance (negative inside), world bounds. |

`meta` carries `name` (used in reports, and matched by prefix: `"nozzle.bell*"`), `owner` (a pipe's own flanges, bands and valve), `touch` (names a tube may touch on purpose, such as the clamps round a harness), `bundle` (cables in one harness may lie side by side, and a clamp carries its harness's bundle), `joined` (runs of one gapped pipe), `limp` (a cable: it holds nothing up), `anchor: false` (a clamp: an end may not count it as its mount), `fitting` (a clamp or collar whose centre `crowding` checks against other lines), `seated` (a pad or rim-on-sphere joint sunk into its host by design), `cut` (a shape or list of shapes subtracted from this one: a saddle pad is a disc cut by its host) and `curve` (the smoothed profile of a turned part with no analytic slope, for `wobble`). Tube runs also carry `tips` (the route's true end points, or `null` for an end that merges into a body, like a volute's start), `tipAxes`, `tipRims` (the flange or connector radius at each end), `free` (ends that are meant to come off their mount apart), `offset` and `total` (where the run sits on its route). Every shape carries the `move`, `ride` and `piece` of the item that draws it, so it can be posed in any explode state.

**States.** An explode state is a number (every assembly at that share) or `{ label, shares }`, one share per assembly. `uniformStates(10)` gives 0, 0.1 … 1; they are not frames anyone sees, so they only feed the key solver. `springStates(order, { stagger, stiffness, damping, substeps, fps = 60, settle = 1e-3, stride = 1, reversals = [] })` replays the live page's own springs with their stagger, opening from rest and closing from apart, one state per frame at `fps` until every spring is within `settle` of its target (about 1.2 s each way on the Raptor, so the slow tail of every collapse is checked too). `stride: 4` keeps every fourth frame, for the depth raster. `reversals` adds the paths a quick hover makes: open, then close again after that many seconds (and the reverse), with the page's own scheduling: on a reversal only the assemblies whose target changes are rescheduled, staggered among themselves, so one that has just left comes straight back and one still waiting simply stays home. **`reversalsOf(order, stagger)`** gives a reversal 8 ms either side of every stagger boundary: that is where the bad windows are (a reversal 15 ms before the next start left the hot-gas duct flying out into a methane pump still parked at home), and three hand-picked times missed every one. **`prefixStates(order, { shares = [0.25, 0.5, 0.75, 1] })`** adds the static states behind the prefix rule: the first `k − 1` assemblies fully out, the `k`-th at each share, the rest home. Shares along the stagger are always non-increasing (earlier assemblies lead, opening, closing and reversing), so every reachable frame lies between these. A 40 ms sample missed whole pass-throughs: between two samples the nozzle travelled 15 units, more than twice the widest pipe. `shareOf(item, state)` reads an item's share from its `ride` (or `piece`), and `offsetOf(item, state)` is its move times that share.

**`overlapsOf(items, P, { step = 0.25, tolerance = 0.3, least = 0.25, states = [0], skip })`** finds every pair of drawn items whose screen footprints overlap in each state and, on a grid of `step` viewBox units over the overlap, ray-casts both shapes along the camera ray. A cell counts when one surface is more than `tolerance` nearer. Each side's flagged cells are closed (one dilate, one erode, so an aliased sliver becomes solid), eroded once (so the hairline along a real contact drops out), and the largest connected piece is measured in px²; a side is "in front" when that area is at least `least`. At the old 0.5 step with a 4-neighbour core count, a whole flange sunk into a pump housing and every cable crossing (a 0.5 cable is under three cells wide) read as noise. Each item is ray-cast once, at home; in any state its raster is that one shifted on screen by its offset (rounded to the grid) and in depth by `offset · V`, which is exact for a translation. Pair results are cached by the pair's relative shift, so pairs that move together are measured once. On the Raptor, 1,119 states (uniform, every fourth frame of every replayed path, prefix states) cost less than the old 61. `skip(a, b)` excludes pairs, such as neighbouring chunks of one pipe.

**`orderKeys(items, overlaps, { gap = 0.02 })`** turns every one-sided pair into a rule in that pair's state: `keyFront − keyBack ≥ gap + shareBack · liftBack − shareFront · liftFront`. It is linear in the keys, so one longest-path relaxation settles all states at once and only ever moves an item later. A loop that can't be met drops its weakest rule (`dropped`). Pairs in front of each other in different places are `cycles`.

**`depthOrder(items, P, { overlaps, solved, rest, allow })`** checks the final order in every state. At rest (`rest`, default the states 0 and 1) and in every replayed spring frame, every wrong pair is a problem: a clean misorder, an interlock and a pair no key of the form `k + lift · share` can hold all show on screen. In the uniform states only a clean misorder counts; interlocks there are listed as passing. `allow` takes `[patternA, patternB]` pairs to excuse explicitly.

**`clearances(tubes, solids, { ratio = 0.5, least = 0.2, state, only, mounts })`** samples every tube and lists every place it comes closer than `max(least, ratio · r)` to a solid or another tube. Near a run's flat end it measures the end ring, not a sphere that would overhang the cut. Only an end's own mount (the shape its rim sits on, see `mountsOf`), that mount's pipe, and the fittings of a line that shares the mount or lands on this tube are excused, and only within `r + gap + 1` of the end; near an end anything else may still not be entered by more than 0.1. Pass `state` to pose everything (the excuse holds only while tube and mount are within 0.05 of where they sit together) and `only: "through"` to list penetrations alone. A tube's distance to another tube is the true capped-cylinder distance, so a run that ends flat under a flange doesn't grow a phantom round cap that the next run seems to enter.

**`mountsOf(tubes, solids)`**, **`terminals(tubes, solids)`** find, for every tube end, the nearest anchored shape to its end rim (12 points at the flange or connector radius) and list every end whose rim isn't within 0.15 of it: a flange standing off its port, a flat flange on a curved body (the rim gap), a cable that stops beside the bundle or inside a clamp.

**`looseEnds(tubes, solids, { state = 1 })`** lists every tube end that, in that explode state, touches no solid of another owner that moves with it. Its own flange, coupling or connector shell does not count (with it, the check could never fail: 25 Raptor ends rested only on their own fitting). Ends marked `free` are reported separately as a deliberate list; everything else fails.

**`supports(solids, tubes, { ground, touch = 0.15, sunk = 0.5 })`** builds a contact graph of solids and rigid tubes and lists every solid not connected to the `ground` (default: names starting `stand.`), plus every pair of solids sunk more than `sunk` into each other unless they share an owner or one is `seated` on the other (named in its `cut`). A seated pad is excused against its host only: the Raptor's igniter flange sunk 0.5 into the hot-gas pad of another assembly hid behind a blanket `seated` flag. `hanging` lists rigid tubes that connect to nothing held, and fails too.

**`solidClearances(solids, { state, sunk = 0.5 })`** poses the solids and, for every pair that sits at a different offset in that state, tests each one's surface points against the other's `sdf`; anything more than `sunk` inside fails (a seated pad and its host are excused). Run it at state 1 and in every replayed and prefix state: a housing, pad or flange of one assembly sweeping through another's body is invisible to the tube checks.

**`seats(solids)`** lists every `seated` shape cut to a host that moves with another assembly. Apart, its cut base is on show: the hot-gas duct's saddle, cut to the methane pump's turbine and ring, flew out as a jagged shard with a pointed tail. A saddle belongs to its host's assembly; the moving line meets it on its flat face.

**`selfOverlaps(items, P, { fold = 0.25, least = 0.5 })`** checks each drawn tube chunk against itself: where the screen centreline turns on a radius under the tube's screen half-width, the inner edge of the ribbon runs backwards and the single-chunk outline crosses itself (a knot with the hidden leg's edges showing through). It fails when that fold is at least `least` px and the spot isn't covered by another item. A leg that points at the viewer and turns within a radius or two of a port is the usual cause.

**`wobble(solids, { samples = 20 })`** samples `dr/ds` of every recorded `curve` at 20 points per segment and lists the `s` values where it turns between the run's ends.

**`folds(routes, P, { fold = 140, turn = 110, pinch = 2, items })`** lists every route (`{ name, points, r }`) whose screen direction turns more than `fold` degrees within four radii while its real turn is under `turn` (the cusp of a pipe that runs away from the viewer and then drops), and every bend that turns more than 90° on screen round a radius under `pinch` × the tube's screen radius, measured over spans of 1.5, 3 and 6 radii (a fillet seen edge-on: the V of a leg that points at the viewer and then climbs). A pinch within three tube widths of an end is a stub seen end-on and doesn't count. With `items`, only bends you can see count: each is ray-cast against every drawn shape.

**`crowding(routes, P, { gap = 1, bend = 30, links, items, allow, state, clear = 2 })`** lists every place where a route's bend (a 3D turn over `bend` degrees within ±2 radii) or end lands on another route's outline on screen: the outlines closer than `gap` px or overlapping, with both lines visible there (ray-cast against `items`; a route's own flanges, shells and clamps count as the route). Those read as a joint or a line threaded through another, whatever the true depth. A bend is never excused by overlapping deeply; an end only when the other line is straight there and the end's tip itself is clear of it. Every `fitting` (a harness clamp) is checked too: a clamp whose outline lands within `clear` px of another line reads as that line growing out of the clamp, as the Raptor's harness clamp did on the oxygen-to-preburner line in the apart pose. `links` (from **`linksOf(tubes, mounts)`**) excuses lines that really meet: one mounted on the other, both on one mount. `state` poses routes and items in an explode state, so the apart pose is checked too (routes carry `move`, `ride`, `piece`). `allow` takes `[patternA, patternB]` pairs you have looked at and judged clear.

**`slenderOf(solids, { ratio = 4, least = 0.3 })`** turns every slender recorded solid (a turned part, prism or box longer than `ratio` × its radius or half-width: tie rods, piston rods, posts, rails, handles) into a pseudo-route, which `audit` passes to `crowding` as `rods`. A tube's bend or end that lands on one fails like one landing on another tube (`crowding: hose.B end × cyl.tie-rod (rod)`), unless the tube is mounted on it or on something linked to it. Rods are only ever the line landed on: their own ends aren't checked.

**`coverage(items, P, { least = 0.6, pad = 1, allow })`** compares, for every item with an `svg` string, the screen box of what it draws (`markupBox(svg)`: every path, circle, line, rect and polyline, through `transform`s and `clip-path`s, without halos, text and defs) with the screen box of its recorded shapes (`footprintOf(shape, cameraBasis(P))`), and lists every item whose shapes cover less than `least` of its drawing: `stand-in shapes: rod.rod  its shapes cover 4% of its drawing`. A sliver recorded for a long rod, or one shape recorded for an item that draws three parts, makes every other check blind to the rest. `audit` takes `cover: [names]` to excuse items whose drawing really reaches past their body; look at each first.

**`report(result)`** formats everything; **`failures(result)`** counts what should fail the build.

The Raptor wires it like this (`examples/raptor-engine/build.mjs`): its `put`, `tubeItems` and `record` are thin wrappers over `R.put`, `R.route` and `R.solid`, and the end of the build is
```js
A.settle(R, P);
items.sort((a, b) => a.key - b.key);
A.auditOrExit(R, P, { crowd: CROWD_OK });
```
The live page reads the same `STAGGER` and `SPRING` from its data, so the audit and the page can't drift apart. Every helper (`turned`, `band`, `flangeAt`, `boss`, `padOn`, `boxAt`, `valveOn`, `pipe`, `cable`, `clampAt`, `strut`, the actuators) records its shape as it puts its item, and the static stand drawn in the back layer (deck, flame cone, deluge ring and feed, columns, splices) is recorded as fixed shapes owned by `stand.frame`, so an explode vector that drove a part into the stand would fail too. Nothing escapes the check.

## 13. The WebGL layer (`kit/gl.mjs`)
| Export | What it is |
|---|---|
| `GLSL_ISO` | The GLSL prelude that `glLayer` prepends. It holds the camera uniforms (`uCanvas`, `uView`, `uOrigin`, `uK`, `uCam`, `uTime`) and the functions `viewOf`, `project`, `towardViewer`, `rayOf`, `pixelOf`, `lightDir`, `onFloor`, `onWallX`, `onWallY`, `hash12`, `hash13`, `noise2`, `noise3`, `fbm2`, `fbm3`, `window` and `tonemap`. Don't redefine any of them in your fragment: GLSL rejects a second `rayOf`. |
| `rayOf(vb)` (GLSL) | The world point under viewBox point `vb` on the vertical plane through the origin that faces the camera. `rayOf(vb) + t·towardViewer()` is that pixel's whole ray; larger `t` is nearer the viewer. |
| `pixelOf()` (GLSL) | World units per canvas pixel, `(uView.x / uCanvas.x) / uK`. Divide by `uCam.z` for the floor's depth direction, or by `uCam.w` up a wall. |
| `glCamera(P)` | `{ origin, k, cam }` for a projection. Works in Node, so put it in the page `DATA`. |
| `glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, onError })` | Returns `{ gl, extensions, set(values), draw(values, time), clear() }`, or `null` without WebGL or on a compile error. `uniforms` names the uniforms you will send per frame. `fixed` is `{ name: value }`, set once at creation, for everything that never changes (plans, heights, theme constants). `extensions` lists GLSL extensions such as `"GL_OES_standard_derivatives"`; each one the context supports is enabled and prepended as `#extension … : enable`, and the returned `extensions` lists those that were. |
| `set(values)` | Sets uniforms without drawing, for a value that changes rarely. |
| `draw(values, time)` | Fits the canvas, sets the camera uniforms and `uTime`, then `values`, and draws. |
| `clear()` | Clears to transparent, only if something was drawn. |
| `smooth(a, b, x)` | Smoothstep in JS. |
| `burst(age, rise, fall)` | An event envelope: a smoothstep attack over `rise` seconds, then exponential decay with time constant `fall`. 0 for `age ≤ 0`. |
| `settle(state, target, dt, seconds, calm = false)` | A critically damped spring on `state = { x, v }` with time constant `seconds`, four sub-steps. Snaps to the target once both error and velocity are below 1e-4. Returns true while still moving. `calm` jumps straight to the target. |

| `GLSL_TURN` | Opt-in GLSL for 3D figures, prepended by the figure to its fragment: `uGroupX/Y/Z/T`, `toGroup(p)` (world → the group's rest-world coordinates), `toWorld(g)`, `dirToGroup(d)`, `dirToWorld(d)`, and the cover masks `uCoverEdge[60]`, `uCoverCount[6]`, `coverOf(vb, soft)` (1 inside a part painted after the effect). It defines `toWorld`: don't prepend it to a fragment that declares its own. The chunk uses 70 uniform vectors. |
| `glslPose(Name, prefix = "uName")` | The same four functions for any group: `toName`, `fromName`, `dirToName`, `dirFromName` over `prefixX/Y/Z/T`, so one shader follows several groups. Feed it `TURN.poseUniforms(controller, group, prefix)`. |

A value for `fixed`, `set` or `draw` can be a number (float), an array of length 2–4 (`vec2`–`vec4`), or an array or `Float32Array` longer than 4, which goes to the uniform array of the declared type: `uniform3fv` for `uniform vec3 name[N]` (cover edges, spark positions), `uniform2fv`/`uniform4fv` likewise, and `uniform1fv` for `uniform float name[N]`. There are no ints, matrices or samplers through these calls; a `sampler2D` defaults to texture unit 0, so bind a texture with `layer.gl` (the ripple tank's history texture). Names that aren't in `uniforms` or `fixed`, and uniforms the compiler optimised away, are skipped.

`smooth`, `burst` and `settle` are plain JS: import them from `kit/gl.mjs` in a module, or get them for free on a page that inlines `glScript()`. `references/webgl.md` has the full contract, a minimal page and the craft rules.

## 14. Tools
| Script | Use it for |
|---|---|
| `scripts/capture.mjs` | One-shot screenshots: desktop, phone, hover, keys, timed shots, 4× zooms, reduced motion, console check. Chrome starts with WebGL on (SwiftShader allowed), so shaders show. `--times` delays are measured from the first shot; `--key` sends the same key codes as `drive.mjs` (`7` is `Digit7`, `Comma` sends `,`). |
| `scripts/drive.mjs` | Scripted sessions in headless Chrome with WebGL on: scroll, move, click, key, slow, until, eval, shots and contact sheets. `--no-webgl` checks the SVG fallback and `--preload file.js` runs a script before the page's own. Use it for shader transitions and anything with a sequence. See `references/verify.md`. |
| `scripts/lines.mjs` | The line-end check: every texture line in the card must end on a stroke, a dot or an outline, or under a later fill. `--out` rings the failures; exits 1 on any. The same check is the `["lines"]` action of `drive.mjs`. See `references/verify.md`, "Line ends". |
| `scripts/inline-kit.mjs` | `kitScript()`, `glScript()` and `turnScript()` return the kit, the WebGL layer and the 3D runtime as plain script, to inline into a standalone page. |
| `scripts/drive.mjs` `["turn", value, group?]` | 3D figures: sets the first turn group (or `group`, or a values object) through `window.__isoTurn` and waits two frames. |
| `scripts/drive.mjs` `["orbit", out.png, from, to, step, sel?, scale?, [x,y,w,h]?, columns?]` | 3D figures: a contact sheet across angles, each frame labelled θ°. 15° over a full turn; 0.1° across an order change; 1° across a fold-prone bend. |
| `scripts/turn-check.mjs` | 3D figures. `build.mjs --fidelity` (Node: prisms, rounds, lathes and tubes against the kit builders at P′(θ)); `page.html --order` (needs a `--verify` build), `--pops`, `--lines`, `--perf`, `--states file.json`, `--part name`, `--out dir`. Every check runs its planted control first. `references/3d.md`, section 14. |
| `scripts/turn-bench.mjs` | 3D figures: `emit` and `order` CPU time in Node over 360 angles, per part kind (median of 7). |

## 15. Turning groups (`kit/turn.mjs`, `turn-build.mjs`, `turn-audit.mjs`)
The optional 3D mode. `references/3d.md` is the guide; this is the reference.
```js
import { turning, separate, gjk } from "../../kit/turn-build.mjs";
import { TURN, TURN_CSS } from "../../kit/turn.mjs";
import * as TA from "../../kit/turn-audit.mjs";
```

### `turning(P, { recorder, scale = 1 })` → `T`
| Member | What it does |
|---|---|
| `T.group(name, { parent?, turn: { pivot } \| slide: { direction, travel } \| hinge: { point, axis, range } \| free: { origin } })` | A rigid group. Values: degrees (turn, hinge), world units (slide), `{ R \| q \| axis+angle, t, world? }` or `null` (free). |
| `T.layer(name)`, `T.stack(names)` | Live layers and the full back-to-front stack of live and static layer names. |
| `T.prism`, `T.round`, `T.lathe`, `T.ball`, `T.tube`, `T.fixed`, `T.plane`, `T.billboard`, `T.split` | Parts, in world coordinates at rest. Common options `{ name, group, layer, tone, crease, lit, material, owner, details }`. Signatures in `3d.md`, section 4. |
| `T.build({ statics, poses, verify, strict })` | `{ svg, data, css, report, layerSvg(name, { width, height }), verify, planesByName }`. |
| `T.parts`, `T.groups`, `T.layers`, `T.routes`, `T.data`, `T.built` | The scene, for the audit and the checks. |

`details`: `seams`, `ribs: { s0, s1, count, phase, twist, fade, seams, tone }`, `bolts: [{ s, r, count, phase, size, tone, fade }]`, `dots: [{ at, normal, size, tone }]`, `rings: [{ at, normal, r, tone }]`, `rules: [{ points, tone, free }]`, `bevel`. Each constructor takes its own subset in its own frame (a ball's `s` runs along its first flat's normal; a prism takes dots, seams and bevel only): the table in `3d.md`, section 4. Materials: `gold`, `chrome`, `steel`, `gunmetal`, `rubber`, `brass`, `copper`.

`separate(A, B, hints)` and `gjk(A, B, warm)` return `{ n, d, margin }` for two point sets (the build's plane solver).

### `TURN` (runtime)
| Member | What it does |
|---|---|
| `TURN.mount(stage, data, { onHold, onFrame, follow })` | The controller: `set(values \| θ, { detail })`, `render()`, `angleAt(x, y, z, group)`, `values`, `pose(name)`, `detach(name)`, `anchor(group, restPoint, restDir?)`, `cover(names, options)`, `order(layer)`, `orderIndex(layer)`, `events()`, `stats()`, `frames()`, `cams()`, `scene()`, `destroy()`. Installs `window.__isoTurn`. |
| `TURN.cover(controller, names \| [names…], { polygons = 6, edges = 10, pad = 0.3, layers })` | `{ uCoverEdge, uCoverCount, dropped, parts }` for `coverOf`. |
| `TURN.poseUniforms(controller, group, prefix = "uGroup")` | Four `vec3` for `GLSL_TURN` or `glslPose`. |
| `TURN.anchor(scene, cams, group, point, dir)`, `TURN.floorOf(scene, group, R)` | Anchors outside a controller; the lowest z of a group under a rotation. |
| `TURN.rotation(axis, deg)`, `TURN.quat(q)`, `TURN.compose(A, B)` | Rotations as `[9]`, for free poses. |
| `TURN.materials`, `TURN.materialCss()`, `TURN.css()`, `TURN_CSS` | Material names; the CSS. |
| `TURN.camera`, `TURN.poses`, `TURN.cams`, `TURN.prepare`, `TURN.emit`, `TURN.order`, `TURN.frame`, `TURN.gjk`, `TURN.disjoint`, `TURN.q`, `TURN.levels` | The pipeline, for tools: cameras per group, a prepared scene, one part's frame, a layer's order. |

### Orbit audit (`kit/turn-audit.mjs`)
| Export | What it does |
|---|---|
| `orbitOrExit(T, R, P, { flag = "--audit", argv, sweep, clips, poses, crowd, cover, fittings, quick, only, log })` | Runs `orbit` when the flag is present, prints the report, sets exit code 1 on any failure. Ends `turn audit passed` only for a full run; with `only` it ends `turn audit partial`, with `quick` `turn audit quick` (`3d.md`, section 13). |
| `orbit(T, R, P, options)`, `orbitReport(result)`, `orbitFailures(result)` | The checks, the text, the count. `result.skipped` lists the lines `only` left out (printed `skipped`); `result.quick` is set for a quick run. |
| `posed(shape, pose)`, `eventsOf(T, layer)` | A recorded shape moved by a group pose; a layer's edge-on angles. |

## references/motion.md

# Motion and interaction

A figure is alive when its mechanism moves the way the real thing would: a carriage glides and settles, a spring overshoots once, a needle swings to the new reading. Motion should be calm enough to follow and true to the model.

## Principles
- **Time-based.** Take `dt` from `requestAnimationFrame` timestamps and clamp it to 1/30 s so a background tab doesn't jump. Never step a fixed amount per frame.
- **Ease toward targets.** Pointer and keyboard set a *target*, and the part follows with exponential easing (`x += (target − x)·(1 − e^(−dt/τ))`, τ = 0.1–0.3 s) or a spring. Parts never snap to the cursor.
- **Real springs where the subject is springy.** Use stiffness and damping from the real model, sub-stepped about 240 times a second for stability. Snap to the target once both error and velocity fall below 5e-4.
- **One loop per figure.** A single `tick(dt)` updates the model, then writes the DOM through refs: `setAttribute("transform" | "d")`, `textContent`, `dataset`. Don't re-render React per frame.
- **Stop when settled or offscreen.** Use an `IntersectionObserver` with rootMargin `120px 0px`, and read the newest entry (`entries[entries.length - 1]`): when the stage leaves and comes back between two callbacks, one callback delivers both entries oldest first, and reading the first one leaves the figure frozen on screen. Stop the loop once everything has reached its target.
- **Reduced motion.** When `prefers-reduced-motion: reduce` is set, jump to targets with no tour, flow or pulse. The figure must still respond to input.
- **Idle tour.** When nobody is interacting, play a scripted loop after about 1.4 s that shows the point of the figure. Pause it on hover, focus or press, and resume about 3.6 s after the last interaction. The tour drives the same targets the user would.
- **Readout updates live** from the same model, in true units and short: `block 2 · reads 14.0 px`, `120.0 × 32.0 · lean 10.6 px`. Read it from where the parts are, not from the target: the Raptor says `taking apart` or `closing up` while any spring is moving or pending, and only then `apart · 11 assemblies` or `1,630 kg · cold`. Take a phase word and the number beside it from the same instant of the same source: a blind-test bench printed `end of stroke · relief 85 bar` (the word from the model, the number from a gauge needle lagging 0.25 s behind it) although its relief valve cracks at 92, and `idle · 44 bar` just after retracting.

## Pointer
- Map the pointer to world space with the screen vector of an axis. Use `axisVector(P, [1, 0, 0])` for motion along x, or map the figure's width fraction onto the part's travel range. The range mapping is simpler and feels better when the travel is short.
- Hover previews and press commits. On touch there is no hover, so make a tap act and keep `touch-action: pan-y` so the page still scrolls.
- Use pointer capture while dragging, and release it on `pointerup` or `pointercancel`.

## Keyboard and accessibility
- The figure's root is focusable (`tabIndex=0`) with `role="slider"` (or `"group"` plus buttons when there are discrete choices), an `aria-label` that explains the controls, and `aria-valuenow`/`aria-valuetext` in words, such as "Pressed: 129.6 by 34.6 pixels, leaning 10.6 px". Write the value text when the reader's choice changes (a part selected, apart or assembled, firing or cold), not from the loop: screen readers announce every change of a focused slider's value text, so a readout ticking each frame ("Pc 290 bar · 222 tf") became a stream of numbers. Keep the visual readout and the accessible value separate (the Raptor's `say()` writes only the readout, `describe()` the value text); the live region carries the phases.
- Keys: arrows step by a sensible unit (Shift for bigger steps), Home and End go to the extremes, Space or Enter presses or toggles, and Escape releases.
- Show a focus ring: `outline: 1px solid var(--anatomy-muted); outline-offset: 6px`.
- The `<svg>` carries `role="img"` and an `aria-label` describing the object and what it shows.

## Vanilla loop (framework-free)
```js
let frame = 0, last = 0, visible = false, touring = !still.matches, clock = 0;
function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  const calm = still.matches;
  if (touring) { clock += dt; target = tourAt(clock); }
  x = calm ? target : x + (target - x) * (1 - Math.exp(-dt / 0.28));
  springTo(tip, heightUnder(x), dt, 420, 26, calm);
  draw();                                   // setAttribute on refs, readout.textContent = …
  const settled = !touring && x === target && tip.rate === 0;
  frame = visible && !settled ? requestAnimationFrame(tick) : 0;
}
const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } };
new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage);
```
`examples/dial-indicator/build.mjs` contains a complete version, with the tour, pointer mapping and keyboard stops.

## React loop
Keep static art in props built on the server and move only `<g>` wrappers and the paths that change shape:

```tsx
"use client";
export function RigLive(props: { base: React.ReactNode; carriage: React.ReactNode; dial: React.ReactNode }) {
  const { anchor, say } = useReadout<HTMLDivElement>();
  const car = React.useRef<SVGGElement>(null);
  const needle = React.useRef<SVGPathElement>(null);
  const state = React.useRef({ x: REST, target: REST, tip: { at: 0, rate: 0 } });
  const visible = useInView(anchor);
  const kick = useLoop((dt, calm) => {
    const s = state.current;
    s.x = follow(s.x, s.target, dt, 0.28, calm);
    const settled = spring(s.tip, heightUnder(s.x), dt, 420, 26, calm);
    car.current?.setAttribute("transform", translateAlong(P, [s.x, 0, 0]));
    needle.current?.setAttribute("d", needleAt(s.tip.at));
    say(readoutOf(s));
    return !(settled && s.x === s.target);
  }, visible);
  return (
    <div ref={anchor} tabIndex={0} role="slider" onPointerMove={(e) => { state.current.target = toWorld(e); kick(); }}>
      <IsoFigure width={W} height={H} label={LABEL}>
        {props.base}
        <g ref={car}>{props.carriage}</g>
        {props.dial}
        <path ref={needle} className="iso-line" data-tone="lit" d={NEEDLE_REST} />
      </IsoFigure>
    </div>
  );
}
```
`examples/test-rig/live.tsx` is the full production version, with springs, a tour, press and pull, keyboard handling and aria.

## Shapes that change
When a part changes shape rather than position (a swelling capsule, a compressing spring, a needle turning), rebuild only its paths each frame with the kit, using `slabOf`, `coil` or `lineOnTop`, and write them to its paths: `fill`, `shades[0..3]`, `top`, `crease`, `bevel`, `outline`. One solid costs well under 0.1 ms. Measure it: a frame should stay under about 1 ms of script.
- **Precomputed frames step.** A lever drawn from 41 prebuilt frames over ±15° moved its knob about 0.8 px per frame, visible in slow motion. Rebuild a rotating part each frame, or precompute enough frames that one step moves nothing more than 0.25 px.
- **A rod that slides out of a gland** doesn't change shape: it is one full-length rod that moves, clipped at the gland face by a clip that stays put (`G.rodOut`, `holdStill`, `craft.md`, section 3). Never draw it as several overlapping copies.

## Envelopes for one-shot events
A flash, a click, a pop, a shake or a shock is an event with an age, not a value you set. Drive it with an envelope that has an attack and a decay. `kit/gl.mjs` exports `smooth(a, b, x)` (smoothstep) and `burst(age, rise, fall)`, which is `smooth(0, rise, age)` times `exp(−(age − rise)/fall)` after the peak and 0 before the event:
```js
import { smooth, burst, settle } from "../../kit/gl.mjs";
const flash = calm ? 0 : burst(clock - mainAt, 0.08, 0.13);
```
On a standalone page `glScript()` already inlines all three, so use them directly and don't declare your own: a second `const smooth` in the same module script is a SyntaxError.
- The attack is 40–120 ms, and the smoothstep gives it zero slope at onset. Setting a value to 1 in one frame is a pop.
- Store the event's start time (`mainAt = clock`) and compute every envelope from its age each frame. Two events that overlap then simply add.
- Give each event its own flag (`popped`), so a quick restart can't swallow the next one and can't cut off one that is still fading.
- Under reduced motion every event envelope is 0.

## Critically damped settle
For a value that should arrive without overshoot (a hue, a brightness, a lamp, a reach, a frequency), use the kit's `settle(state, target, dt, seconds, calm)`: a critically damped spring on `state = { x, v }` with time constant `seconds`, `v += (w²·(target − x) − 2w·v)·dt` with `w = 1/seconds`, in four sub-steps. It is smoother than exponential follow, because velocity is continuous too. It snaps to the target once error and velocity are both below 1e-4, jumps there under `calm`, and returns true while still moving, so it can keep the loop alive:
```js
const moving = settle(freq, state.tf, dt, 0.625, calm);
```
The ripple tank runs its frequency (0.625 s) and dipper spacing (0.4 s) through `settle`, so a key press or a pointer move never jumps the wavelength.
- **Separate what a phase changes.** When one physical quantity (say chamber pressure) drives colour, shape and brightness, a fast spring on that quantity makes all three jump at once. Give colour its own slower `settle` (0.3–1 s), and compute brightness so it moves one way through the change.
- **A fade-out only gets darker.** When a stop begins, freeze the brightness at its current level and scale it only by quantities that fall.
- **Changing a spring changes its neighbours.** Damping one spring critically slows every phase that relied on its old overshoot, so recheck all of them.

## Exploded views
Taking a machine apart on hover is a strong way to show its parts (`examples/raptor-engine/`):
- **Build every piece at its assembled position**, tagged with an assembly name, and give each assembly a world move vector (`PIECES.nozzle.move = [0, 0, -120]`). Moves follow the real assembly: the nozzle drops furthest, side units slide out radially (`out(deg, r, z)`), and the stand doesn't move.
- **One spring per assembly**, with value 0 for assembled and 1 for apart. The Raptor uses stiffness 64 and drag 14.2, just under critical, so the parts settle with a hint of weight, and sub-steps 4 times per frame.
- **Stagger the springs.** Opening, each assembly's target changes 35 ms after the previous one, in an order that lets nothing pass through anything (the Raptor: `nozzle, downcomer, controller, plumbing, lox, ftp, hotgas, chamber, orpb, otp, gimbal, stand`, see "Order the stagger" below). Closing uses the reverse order, so the machine folds back together from the inside out. Keep pending targets as `{ value, at }` and apply them when `clock >= at`.
- **On a reversal, reschedule only what changes.** When the reader reverses mid-way (a quick hover, Space, a tap), cancel the pending target of every assembly that hasn't left yet and stagger only the ones whose target changes, by their index in that shorter list. The last one out is then the first one back, at once. Rescheduling the whole list left the early assemblies flying out toward apart for 0.3 s while later ones sat at home, and the explode only worked because the next assembly moved out of the way 35 ms later: a reversal in that window drove the hot-gas duct into a methane pump that hadn't left. With this rule, shares along the stagger stay in order in every frame (earlier assemblies always lead), which is what the prefix rule below relies on.
- **Move with transforms.** Each item's screen offset is its move vector, projected and multiplied by the spring value: `translate(tx ty)`. Remove the attribute at 0, so an assembled figure is exactly the static art.
- **Keep the lines landing.** Parts that join two assemblies (an actuator between the frame and a pump lug, or a cable between a box and a plug) belong to the assembly they physically stay with, and they still end on something when apart. Cables get terminal dots at both ends.
- **Selection.** Hover, the keyboard arrows and a parts list all select one assembly. The selected parts become the one `lit` accent: CSS on `.it[data-on]` moves `hi` to `lit` and `mid` to `hi`, and switches tops and sides to the lit fills. Every other part steps down one tone (`hi` to `mid`, `mid` to `lo`) through CSS on `[data-focus]`, and the readout says what the selected part does.

## Depth re-sorting of moving parts
Parts that move toward or away from the viewer change painter's order as they move. When they do, sort every frame:
- Give every item a static key `k = dot3(ref, viewOf(P)) + bias`, where `ref` is its centre and `bias` settles ties between touching parts. Also give it a lift: `lift = dot3(move, viewOf(P))`.
- Each frame, its key is `k + lift × spring`. Sort the indices by that key, and **re-append the DOM nodes only when the order actually changed** (one `DocumentFragment`). Most frames change nothing.
- Store the key and the move vector on the element (`data-k`, `data-m`), so the live script needs no geometry.
- Tubes are sorted per piece (`tubePieces` → `piece.mid`), so a pipe can pass in front of one part and behind another.
- **Settle the static keys from the motion, not just the rest poses.** Each item's key is `k + lift × spring`, but with staggered springs every assembly has its own share in any frame, so two items that are ordered right assembled and apart can still be drawn the wrong way round in between: in the first Raptor a pressurisation line hid behind an actuator column it was in front of for 40% of every explode. The Raptor measures true depth for every overlapping pair at 0, 0.1 … 1, at every fourth frame of its own springs replayed with the live stagger until they settle, opening, closing and reversed either side of every stagger boundary, and in the prefix states (`A.explodeStates(STAGGER, SPRING).depth`, which is `[...A.uniformStates(10), ...A.springStates(STAGGER, { ...SPRING, stride: 4, reversals: A.reversalsOf(STAGGER, SPRING.stagger) }), ...A.prefixStates(STAGGER)]`, 1,119 states, and the default of `A.settle(R, P)` for a recorder made with `{ order: STAGGER, spring: SPRING }`), and `orderKeys` solves all of them at once (each rule uses that frame's shares). Opening and closing alone left wrong pairs in the frames a quick hover makes: a purge flange that should sit in front of the chamber band vanished behind it on a reversal at 0.20 s. The audit fails on any wrong pair at rest and in any replayed frame; only the uniform states, which nobody sees, may hold an interlock.
- **One source for the springs.** Keep the stagger order and the spring constants (`SPRING = { stagger, stiffness, damping, substeps }`) in the build and pass them to the page in its data, so the audit replays exactly what the viewer sees.
- **Move a part with what it is mounted on (`ride`).** A sensor bolted to the chamber highlights with the plumbing but rides the chamber's spring. Each item records `ride` (the assembly whose spring moves it, found from its move vector) next to `piece` (the assembly it highlights with), the page reads `data-ride`, and `place()` uses `piece[node.ride].x` for both the translate and the sort. An item that borrows another assembly's vector but keeps its own spring slides along its mount and sinks into it mid-flight.
- **The prefix rule.** Every assembly's path must be clear of every later-leaving assembly parked at home, with the earlier ones out: that is the frame a reversal at a stagger boundary freezes. `A.prefixStates(STAGGER)` poses exactly those (the first `k − 1` out, the `k`-th at 0.25 … 1, the rest home) and the audit checks them with the replayed frames. On the Raptor it moved the hot-gas manifold after the methane pump it bolts to (its vector points into the pump), and the controller before the plumbing (the oxygen-to-preburner line is enclosed by the controller's sense lines, so it can only leave after them).
- **Order the stagger so nothing has to pass through.** The springs overlap: with 35 ms between starts and about a second to settle, every assembly is moving at once, and relative motion is a blend of every vector. The first Raptor explode passed 66 pairs through each other in mid-flight. The rules that took it to zero, checked every frame and on reversals by the audit:
  - **Whoever is outside leaves first.** On each side, the part that everything else is tucked behind or under moves first: the Raptor's nozzle drops before anything (everything sits on it), and the downcomer, which runs outside every sense line and harness that reaches the methane pump, goes second. Closing reverses it, so the outer part is also the last one home.
  - **When a part's explode vector points into a face of another assembly, that assembly comes earlier in the order.** The downcomer drops 84 onto the nozzle's port tee; the nozzle drops 120 and leaves 35 ms earlier, so the port is always below the flange, opening and closing.
  - **Point every vector away from every face the part touches while the other side is still in place**, not only the two it bolts to: a line threaded between the chamber and the pump must move in the sector both mount normals allow (the Raptor's jacket lines go to −120° with no drop, sliding off both pads), a cable wrapped round a core part may only move in the sector that is outward for its whole wrap (a 90° wrap allows 90°; a wrap over 180° allows nothing, so shorten it or end it on the near side), and a line that would sweep down over a part bolted below it must not drop.
  - **Things threaded through each other share a spring.** Sense lines that rise through the harness ring into a block, and the harness under that block, cannot have separate springs: with 35 ms between them they shear past each other. The Raptor's sense lines and block ride the controller (`BLOCK_MOVE = PIECES.controller.move`, so `ride` resolves to `controller`) while still highlighting as plumbing.
  - **Give each line that is mounted on one part only the move of that part.**
  - **Check the paths a viewer can make**, not only the two from rest: a quick hover reverses the springs halfway (`reversals`), and an assembly that is late on the way back can sweep through a cable that is already home. Reverse at every stagger boundary (`A.reversalsOf`), not at a few round times: the bad windows are about 15 ms wide, and 0.15, 0.3 and 0.5 s missed all of them. Feed those frames to the depth states as well as to the clearance check, and test solids against solids too (`A.solidClearances`): a flange tucked under an overhang on its host met the host's spring tail on the way home, 0.67 deep, with every tube check at zero.

## Turning by hand
For a 3D figure (`references/3d.md`):
- **Drag.** `controller.angleAt(clientX, clientY, zFace, "dial")` is the pointer's angle on the face's plane, unwrapped across turns. On pointer down, store it and the current target. On move, set target = start + Δ, and capture the pointer once the move passes about 1.5°. The group follows with exponential follow (τ = 0.12 s). The stage keeps `touch-action: pan-y`.
- **Keys.** Arrow keys step by one division, Shift steps by ten, Home returns to the index.
- **Coupled groups.** Drive them from one value through `follow(values)` (a wheel at a quarter of the dial, a finger chain from one `lift`), so a tool, a drag and the tour all agree.
- **Gestures and clips are pure functions of time** (`tapAt(t)`, `breakAt(t)`): the build samples them, the audit walks them frame by frame, and the page can play them backwards to reset with no jump.
- **Hook takeover.** Any `window.__isoTurn` call runs `onHold`: stop the tour, and keep values a tool set until the reader acts again.
- **Half rate.** A dense figure keeps an average of `controller.set` time and renders on alternate frames above 14 ms, every frame below 9 ms, with time-based motion unchanged.
- Reduced motion gets one still frame. Offscreen, the loop stops (IntersectionObserver).

## Arming
If an action needs the machine in a certain state (assembled before firing, door closed before spinning), arm first:
1. Set the targets that bring it into that state (`setApart(false)`, `select(null)`).
2. Mark it armed: the button shows `data-state="armed"` and the readout says `closing up · arming`.
3. Start the action's clock only when every spring has arrived and nothing is pending.

Never run the action on a half-exploded machine, and never snap the parts home to save time.

## Taking over from the tour
When the user acts during the idle tour, stop the tour and leave the state where it is. If your take-over helper resets something (for example, it forces the power on), and the user's action is itself a toggle of that thing, the two cancel out in the same frame and replay an animation. Pass the helper a flag (`takeOver(switching)`), and test every toggle in every phase of the tour, not only the default one.

A step key pressed during the tour must step from the value the reader sees, not from the tour's target, which may be far ahead. The ripple tank's keys start from `freq.x` and `gap.x` while touring (`state.tf` and `state.td` otherwise), and `steer()` zeroes both springs' velocity when it takes over, so the value only moves in the key's direction.

## Announcing phases
For a machine with phases, add a visually hidden `aria-live="polite"` element and write only phase changes to it: `arming`, `chill-down, venting`, `preburners lit`, `main stage`, `shutdown`, `cold`. Never write per-frame numbers to it. For a continuous control, wait until the input has been quiet for about 700 ms before you announce (the ripple tank's `announce`). Leave the live region empty at load and fill it with the current state on `focus`, so a screen reader hears the state when it arrives rather than a stale line. A button whose label changes ("Ignite" to "Shut down") doesn't also need `aria-pressed`.

## CSS motion
- `.iso-line[data-flow]` animates dashes, for things travelling along a path.
- `.iso-dots circle[data-pulse]` blinks a dot grid slowly, for an "active" surface.
- For hover lifts, wrap a group with `transition: transform 700ms var(--anatomy-ease)` and translate it up on hover.

All of these switch off under reduced motion in `ISO_CSS`.

## references/react.md

# React and Next.js

## File layout for one figure
```
components/iso/                 the kit: iso-kit.ts, iso.css, react/draw.tsx, react/live.tsx
components/figures/rig/
  model.ts       pure model: true numbers, springs, readout text (no DOM)
  view.ts        world layout constants, the projection P (fitProjection), helpers like at(point)
  geometry.ts    every static path, computed once at module load
  frame.ts       paths for the parts that change shape: frameOf(state) -> { glass, coils, needle, ... }
  art.tsx        server component: static parts as JSX constants, passed to the live component
  live.tsx       "use client": refs, loop, pointer and keyboard, aria, writes the readout
  rig.css        scoped tokens and styles (e.g. .rig { … })
  figure.tsx     <Plate fig title hint readout keys caption><RigArt /></Plate>
```
`examples/test-rig/` follows this layout. It uses `rig.ts`, `view.ts`, `geometry.ts`, `pose.ts`, `art.tsx`, `live.tsx` and `figure.tsx`.

## The server/client split
- Geometry is pure TypeScript, so it runs on the server and the client receives finished path strings. No geometry code is shipped unless a moving part needs to rebuild per frame. Then `frame.ts` is imported by `live.tsx` and ships to the client.
- `art.tsx` builds the static parts as JSX (`<Solid paths={BASE_ART.slab} tone="mid" />` and so on) and passes them as element props, such as `base`, `rail`, `carriage` and `dial`, to the `"use client"` live component. The live component places them in painter's order and wraps the moving ones in `<g ref>`.
- Only the live component and `kit/react/live.tsx` are client code. `draw.tsx` is server-safe.

## The large-page key-warning trap
On a big page the server payload is large, and React streams some element props as deferred (lazy) chunks. The client renders `{props.dial}` among siblings. It can't mark a lazy as key-checked, so once the lazy resolves to an element without a key, React logs "Each child in a list should have a unique key", pointing at your live component or at `IsoFigure`. The same figure on a small test page logs nothing, so checking one figure at a time misses it.

There are two fixes, and you should do both from the start:

1. **Give the root of every element prop a stable key:** `const DIAL = (<g key="dial">…</g>)`, and `<React.Fragment key="sled">` for fragments. Keys on a single element cost nothing.
2. **Keep `"use client"` components out of server-built art.** Every client component element inside a server tree becomes a lazy reference, which makes all of its ancestors lazy too. If parts of the art need interactivity (hover-to-highlight, for example), make them plain `<g data-part="…">` and handle events with delegation in the live component.

Any array you build with `.map` needs keys as usual.

## Styles
- Import `iso.css` once, globally or in the figure. Put `className="iso"` on a wrapper (or `<body>`) for the tokens, plus `data-theme="light"` for the light theme.
- Scope per-figure tokens to the figure's class (`.rig { --rig-glass-top: … }`) and override kit tokens inside `.iso-plate` only when the card needs it.
- Don't put figure tokens on `:root`.

## Readout
`useReadout()` finds the nearest `.iso-plate [data-readout]` from its `anchor`, and `say(text)` writes it without re-rendering. Render the resting text on the server so it is right before hydration.

## Hydration
Render the rest pose on the server and on the client's first render; start motion only in effects. Don't read `window` while rendering. `asksForStillness()` is safe because it is only called inside effects and loops.

## references/verify.md

# Verify

Look at the figure the way a demanding reviewer would, before anyone else does. Rendering it is cheap, and guessing is expensive.

## Capture
`scripts/capture.mjs` starts its own headless Chrome (set `CHROME_PATH` if it can't find one), loads a file or URL, and clips to `.iso-plate` by default. It also prints the readout, any console errors or exceptions, and whether the page scrolls sideways.

```sh
node scripts/capture.mjs page.html --out shots/desk.png                         # desktop 1440, dpr 2
node scripts/capture.mjs page.html --out shots/phone.png --width 390            # phone
node scripts/capture.mjs page.html --out shots/hover.png --hover 0.7,0.5        # pointer at 70% across
node scripts/capture.mjs page.html --out shots/tour.png --times 1200,2400,3600  # motion: extra shots over time
node scripts/capture.mjs page.html --out shots/keys.png --key ArrowRight,ArrowRight
node scripts/capture.mjs page.html --out shots/joint.png --zoom 200,80,220,170  # close-up at 4x, in card px
node scripts/capture.mjs page.html --out shots/still.png --reduced              # reduced motion
node scripts/capture.mjs http://localhost:3000/docs --select "figure:nth-of-type(3) .iso-plate"   # one figure on a dev-server page
```
`--select` takes any CSS selector; by default it clips to the first `.iso-plate`. Each `--times` delay counts from the first shot, so `1200,2400,3600` shoots at about 1.2, 2.4 and 3.6 s after it (a capture takes 60–200 ms, so delays closer together than that run late), saved as `name-<ms>.png`. `--key` sends the same key codes as `drive.mjs` (`7` as `Digit7`, `.` as `Period`; write a comma as `Comma`, since the list is comma separated). The other options are `--height` (1000), `--scale` (2), `--wait` (2500 ms before the first shot), `--zoom-scale` (4) and `--theme dark|light`. Read every PNG you make: open it and look.

Both scripts start Chrome with WebGL on (`--enable-unsafe-swiftshader --ignore-gpu-blocklist`), so shaders show in `capture.mjs` stills too. Use `drive.mjs` for anything that has to be timed or slowed down.

## Drive
`scripts/drive.mjs` runs a scripted session in headless Chrome with WebGL on. Use it for sequences: hover, then click, then type, then wait for a phase. Use it also for slowed-down contact sheets that catch pops, and for the no-WebGL fallback. It prints the readout after each shot, then any console errors and exceptions.

```sh
node scripts/drive.mjs page.html '[["wait",1500],["move",".rt-stage",0.7,0.4],["wait",600],["shot","shots/hover.png"]]'
node scripts/drive.mjs page.html --actions steps.json --height 1300
node scripts/drive.mjs page.html --actions steps.json --width 390
node scripts/drive.mjs page-light.html --actions steps.json --reduced
node scripts/drive.mjs page.html --actions steps.json --no-webgl
node scripts/drive.mjs page-light.html --actions steps.json --preload slow.js
```
Options are `--width` (1440), `--height` (1100), `--scale` (1), `--reduced`, `--theme dark|light`, `--no-webgl` (every `getContext("webgl")` or `"webgl2"` returns `null`, to check the SVG fallback), `--preload file.js` (a script that runs before the page's own scripts: counters, stubs, hooks, or `window.__driveSpeed=0.04` to run slowly from the first frame) and `--actions file.json`. Selectors default to the first `.iso-plate`, and fractions run 0..1 across the element.

`--theme` (in both scripts) only emulates `prefers-color-scheme`. Kit pages choose their theme when they are built (`data-theme` on `<body>`) and ignore that media query, so to check the light theme, build with `--light` and drive the `-light.html` file.

| Action | What it does |
|---|---|
| `["scroll", sel]` | Scrolls the element into the middle of the viewport. |
| `["wait", ms]` | Waits. |
| `["move", sel, fx, fy]` | Glides the mouse there in 10 steps, firing `pointermove`. |
| `["click", sel, fx?, fy?]` | Clicks, at the centre by default. |
| `["drag", sel, fx0, fy0, fx1, fy1, steps?]` | Presses at one point, glides to the other in `steps` (20) moves a frame apart, releases. These are real input events (pointer id 1), so a handler that calls `setPointerCapture` works. Test drag-to-turn with it: `PointerEvent`s dispatched from `eval` have no active pointer, and `setPointerCapture` throws `NotFoundError` and aborts the handler. |
| `["key", key, sel?, code?]` | Focuses `sel` (default `[tabindex="0"]`) and presses a key: `"i"`, `"Escape"`, `"ArrowRight"`, `" "`, `"7"`. Digits send `DigitN`, letters `KeyX` and punctuation its proper code (`Space`, `Minus`, `Comma`…). Pass `code` for keys that share a name: `["key", "Shift", null, "ShiftRight"]`, `["key", "Enter", null, "NumpadEnter"]`. |
| `["slow", factor]` | Runs the page's `requestAnimationFrame` timestamps and `performance.now` at `factor` × real time: `["slow", 0.05]` is 20× slow motion, so a sheet catches a 100 ms event in many frames. `["slow", 1]` restores real time. |
| `["until", js, timeoutMs?]` | Polls a JS expression every 25 ms until it is truthy (default 10 s), and prints a line if it times out. |
| `["eval", js]` | Runs JS in the page and prints the result. Promises are awaited. |
| `["shot", out, sel?, scale?, [x,y,w,h]?]` | Screenshots an element, or a region of it in CSS px. |
| `["sheet", out, every, count, sel?, scale?, [x,y,w,h]?, columns?]` | Takes `count` frames `every` ms apart and tiles them into one numbered PNG. |
| `["turn", value, group?]` | 3D figures: sets `window.__isoTurn` (a number sets the first turn group, `group` names another, an object sets several) and waits two frames. |
| `["orbit", out, from, to, step, sel?, scale?, [x,y,w,h]?, columns?]` | 3D figures: a contact sheet across angles of the first turn group, each frame labelled. `["orbit", "swap.png", 129.7, 130.2, 0.1, null, 6, [x, y, w, h]]` is a 6× close-up of one order change. |
| `["lines", out?, sel?, { tolerance }?]` | The line-end check below, in whatever pose the actions before it left the figure (apart, mid-stroke). `out` writes the card with every floating end ringed. A failure makes the run exit 1. |

Traps:
- **Shots are correct after `scroll`**, so you can scroll to a figure lower on a page. A viewport tall enough for the whole card (`--height 1300`) is still the simplest: the figure is visible at scroll 0, and its `IntersectionObserver` starts it.
- **Frame spacing in a sheet is `every` plus the capture time** (60–200 ms, more on heavy WebGL pages, where capture also stalls animation frames). For transitions faster than that, put `["slow", 0.05]` before the sheet and `["slow", 1]` after it. `wait`, `every` and the page's `setTimeout` timers stay in real time, so at 0.05 a sheet every 100 ms is 5 ms of page time apart, and an idle tour's resume timer still fires on the wall clock: a key press holds it off for only about 3.6 s of real time, so press a harmless key again before each slowed sheet. An event that starts on load needs the page slow from its first frame: `--preload` a file with `window.__driveSpeed=0.04`.
- **Slow mode doesn't slow timers.** `setTimeout` runs on the wall clock, so during a long slowed capture an idle tour's resume timer (the Raptor's 3.6 s `quietTour`) can restart the tour and close the engine mid-sheet. Before every slowed sheet stop the tour and wait for rest: preload a stub for that timer (`const st = window.setTimeout; window.setTimeout = (f, d, ...a) => (d === 3600 ? 0 : st(f, d, ...a));`), press `Escape`, `["until", "document.querySelector('[data-readout]').textContent.includes('cold')"]`, and only then the key that starts the transition. A sheet labelled "opening" that starts from a tour that has already opened films a close.
- **Wait for a phase, don't guess.** `["until", "document.querySelector('[data-readout]').textContent.includes('main stage')", 8000]` returns as soon as the readout reaches the phase.
- **A `null` selector is the default.** In every action a missing or `null` selector means the first `.iso-plate` (for `key`, the focusable `[tabindex="0"]`), so `["sheet", "s.png", 200, 16, null, 0.5]` films the card.
- **Long eval strings break inline JSON quoting.** Put anything with `eval` or `until` in `--actions steps.json`.
- **Pages inline their live code as `<script type="module">`,** so top-level variables aren't visible to `eval`. Read state through the DOM instead: readout text, `aria-valuetext`, `data-*` attributes. To count frames or read a value per frame, install a hook with `--preload` (it runs before the page, so it can wrap `requestAnimationFrame` or `HTMLCanvasElement.prototype.getContext`).

## Line ends
The audit sees recorded shapes, never line markup, so it can't see a seam, rule, rib or tick that stops in the middle of a face. `scripts/lines.mjs` checks the drawing itself, in headless Chrome:

```sh
node scripts/lines.mjs page.html --out shots/lines.png
node scripts/lines.mjs page-light.html --out shots/lines-light.png --reduced
node scripts/drive.mjs page.html '[["key"," "],["wait",2000],["lines","shots/lines-apart.png"]]'
```
```
line ends: 3 of 1148 ends on 295 lines float
  iso-line faint in it k -0.080  end  7.25 px from anything   at card 507.4,407.2   length 75.7   d M392.1 381.7L507.4 407.2…
```
Every open subpath of every `.iso-line` in the card (seams, grooves, ribs from `fadedSvg`, rule lines, ticks, needles, pointers, wires) must end within 1 viewBox px of another stroke, a dot or an outline, or under a fill painted after it. A short straight mark (up to 7 px: a tick) needs one end rooted within 2 px; both ends floating fails. The kit's own outlines, creases, bevels and tube edges aren't checked, and neither are ends that a `fadeLineSvg` ramp fades out. `lineSvg(d, { free: true })` (or `"start"`, `"end"`) marks an end that is meant to stop: rule lines that stand for printed text, a needle's tail, a pointer's tip. Use it for nothing else. It prints each floating end in card px, ready for `capture.mjs --zoom`, with the start of its `d` so you can find it in the build; `--out` rings them on a 2× shot of the card. Run it on both themes and in every pose the figure rests in, and fix every line until it prints 0:

| You see | Fix |
|---|---|
| A seam or weld line on a box inset from the corners | `k.sideSeam(plan, z, P, steps)` as the slab's `inner`: it wraps the corners and ends on the silhouette (`craft.md`, section 3). |
| A stiffener or panel line on one face, inset at both ends | Run it edge to edge: from the face's bottom to its top, or from end to end. |
| Ribs that stop short of their seams | `ribsOf(…, { seams: true })`, or `s0`, `s1` on the band's ends. |
| Ticks floating on a bare plate or hanging below an edge | Put them on a ruler strip, rooted on its edge (`topTicks` `edge`, `sideTicks` `top` on the face's top edge). |
| Vent or grille lines | Closed slots: a thin rounded outline, not a line. |
| A line that runs under a part and stops just past it | Draw it before the part, full length, and let the part hide it. |
| Rule lines on a label plate | `free: true`. They stand for text. |

## Audit
Screenshots catch what you think to look at. A figure with pipes, cables and round parts also needs the geometry checked, because a cable through a duct or a part drawn over the thing in front of it is invisible at 1× and obvious at 4×. Wire `kit/audit.mjs` into the build (`kit.md`, section 12): helpers put into one `A.recorder(P, { order, spring })`, the build calls `A.settle(R, P)` and ends with `A.auditOrExit(R, P)`, then run:

```sh
node examples/raptor-engine/build.mjs --audit
```
```
clearance: 0 problems
clearance apart: 0 problems
terminals: 0 ends not on a mount
loose ends: 0
screen folds: 0
support: 0 unsupported solids · 0 hanging tubes · 0 sunk pairs
depth order: 0 problems
through in mid-flight: 0 pairs
self-overlap: 0 chunks whose outline crosses itself on screen
wobble: 0 smoothed profiles whose slope turns between its ends
saddles off their host apart: 0
stand-in shapes: 0 items whose recorded shape covers too little of what it draws
crowding: 0 pairs where a bend or end touches another line on screen
free ends apart (marked free, not failing): 35
audit · 80 tubes · 267 solids · 478 items · 1119 depth states · 1707702 overlaps · 4292 order rules · 151.9 s
audit passed
```
It prints `audit passed` or `audit failed: n problems` and exits non-zero on any failure. Every line counts, including what happens in mid-flight and `items with no shape`, except two lists that are there to be read: `in passing` and `free ends apart`. Depth order and pass-through (tube against everything, and solid against solid) are both checked on the same replayed frames: every frame of the springs opening and closing until they settle, every frame of a reversal 8 ms either side of each stagger boundary (`reversalsOf`: 0.027, 0.043, 0.062, 0.078 … 0.393 s on the Raptor's 35 ms stagger), and the prefix states (`prefixStates`). Depth uses every fourth of those frames. How to read and fix each line:

| Line | Meaning | Fix |
|---|---|---|
| `through A × B  clearance -2.4` | Tube A intersects B over that arc-length span. | Reroute A round B with about 1.5 radii to spare, or move B. Only the shape A's end rim sits on is excused, and only within a radius or so of that end. |
| `close A × B  clearance 0.2 < 0.6` | A passes B with less than half its radius to spare. | Nudge the route; widen harness lane spacing. |
| `clearance apart: through share 1 …` | In the apart state a tube passes through something. | Change that assembly's explode vector. |
| `terminals: A end 1.40 from B (rim -1.1…1.3)` | A's end, or the rim of its end flange, doesn't sit on its mount: it floats, it is tilted, or the flange is wider than the boss under it. | End the route exactly on the surface, along the surface normal; make the boss at least as wide as the flange; on a curved body seat the flange on a saddle pad. A cable never ends on a clamp. |
| `loose ends: share 1 A end touches nothing that moves with it` | Apart, the end hangs in the air: nothing of another owner moves with it (its own flange or shell doesn't count). | Give it a mount that moves with it, or, if it is meant to come off (a flanged duct, a connector on another assembly), mark it `free` so it is listed under "free ends" as a choice. |
| `self-overlap: A:3 its outline folds back on screen` | One chunk's outline crosses itself: the line turns on a screen radius under its own half-width (a leg pointing at the viewer that turns near a port). | Leave the port through an elbow ball (the Raptor's `elbow`), give the turn more straight leg, or bend in a plane that faces the viewer (the Raptor's `sideOn`, `craft.md`, section 8). |
| `wobble: A dr/ds turns at s = …` | A profile smoothed from typed knots has a slope that plateaus and reverses, so its tone bands staircase. | Give it an analytic `r(s)` and pass `smooth: { slope }`. |
| `saddles off their host apart: A (piece) is cut to B (host piece)` | A saddle pad flies off with another assembly, showing its cut base. | Give the pad to the host's assembly; the moving pipe leaves its flat face. |
| `solid  state  A × B  sunk 1.81 deep` | Two solids of different assemblies pass through each other in that frame. | As for a tube: fix the order or the vectors, or open the gap under an overhang. |
| `screen folds: A turns 150° on screen for 78° in 3D` | The route doubles back on screen and reads as a cusp. | Route the leg before the turn sideways on screen. |
| `screen folds: A turns 110° on screen round a 0.4 px bend` | A visible bend pinches to a point: its fillet is seen edge-on, so the two legs meet in a V. | Turn inside a fitting (a ball tee, the line leaving at 45°), bend in a plane that faces the viewer, or move the port so its normal lies across the screen. |
| `through in mid-flight: apart 0.233 s A × B clearance -1.6` | In that replayed frame A passes through B. | Fix the order or the vectors (`motion.md`, "Order the stagger"): the part outside leaves first, a vector that points into another assembly's face needs that assembly to leave earlier, threaded parts share a spring. |
| `crowding: A bend × B outlines overlap 0.05 px` | A's bend or end lands on B's outline on screen and reads as a joint or a line threaded through B. | Reroute so the meeting is a clear crossing of two straights, or leave a visible gap. Excuse a pair (`allow`) only after looking at it at 8×. |
| `crowding: A end × B (rod)` | A's end or bend lands on the outline of a slender solid: a tie rod, piston rod, post, rail or handle (any solid longer than 4× its radius or half-width). It reads as the rod plugged into A's fitting. | Approach the port across the rod, or turn the elbow so the last leg lies across the screen. |
| `stand-in shapes: A its shapes cover 4% of its drawing` | The shapes recorded for A cover much less of the screen than A draws, so the audit checks a sliver and says nothing about the rest. | Record the true shape of what you draw (`A.slide` for a rod that slides out of a gland). Excuse an item (`cover: [name]`) only when its drawing really reaches past its body, and only after looking at it. |
| `crowding: C fitting × B` | A clamp lands within 2 px of another line and reads as that line growing out of it. | Change the explode vector or the clamp's angle. |
| `support: loose A` / `hanging A` / `sunk A × B 1.2 deep` | A solid, or a rigid tube, touches nothing that leads to the stand, or is buried in B. | Add the bracket, stud or plate that holds it; for a sunk part fix the contact (rim on surface, pad cut by its host marked `seated`; `seated` only excuses the host in its `cut`). |
| `order  A is in front of B but drawn first` | A's surface is nearer wherever they overlap, but B paints over it, at rest or in a mid-flight frame. | `orderKeys` normally fixes these; one left over means a rule was dropped from a loop, so look for the interlocked pair in that loop. |
| `cycle  A … (n the other way)` | A and B are each in front of the other somewhere: they interpenetrate on screen. | Change the geometry: gap the pipe at its collar, split the band at the ring, end the rod on the ball, make the volute tangent, put the valve on a straight, keep a pad off its host's silhouette. |
| `in passing` | Interlocks, or can't be ordered by any `k + lift · share`, in a uniform state only (all assemblies at one share, which the page never shows). | Not a failure. The same thing in a replayed frame is a `depth order` problem. |
| `items with no shape` | Something is drawn without a recorded shape, so nothing checks it. | Record it in the helper that draws it. |

A zero audit is necessary, not sufficient: it can't see a seam, a staircase or a texture that stops, and it only checks what you recorded. A stand-in shape that silences it is a bug, which is why it fails one. Run the line check, then look.

## Orbit (3D figures)
A figure with turning groups (`references/3d.md`) adds the orbit audit and the browser checks. Run them on the light page and the dark page.
```sh
node build.mjs --light --audit > audit.txt 2>&1 &   # the full run, in the background: ends "turn audit passed"
node build.mjs --light --audit --quick    # ≈ 10× fewer states while iterating: ends "turn audit quick"
node build.mjs --light --audit --quick --only=order,continuity   # a subset: the rest print "skipped", ends "turn audit partial"
node build.mjs --light --verify           # a page with window.__isoVerify (recorded shapes) for --order
node ../../scripts/turn-check.mjs build.mjs --fidelity
node ../../scripts/turn-check.mjs page-verify-light.html --order --step 1
node ../../scripts/turn-check.mjs page-verify-light.html --order --states clip.json     # hinges and clips: --step turns the first turn group only
node ../../scripts/turn-check.mjs page-light.html --pops --no-webgl --states tilt-path.json   # one hinge as a continuous path at 0.02°
node ../../scripts/turn-check.mjs page-light.html --pops --no-webgl --step 1 --out shots/pops
node ../../scripts/turn-check.mjs page-light.html --lines
node ../../scripts/turn-check.mjs page-light.html --perf --width 1440
node ../../scripts/turn-check.mjs page-light.html --perf --width 390 --throttle 4
node ../../scripts/drive.mjs page-light.html '[["orbit", "shots/orbit.png", 0, 345, 15]]'
```
**Time.** A 28-part figure with one turn, one hinge and one clip took 85 s quick and 17 minutes full, past a 10-minute shell timeout: run the full audit in the background. Quick runs undersample (`continuity` checks 100 halving candidates instead of 800, and the order scan a tenth of the states), so they guide, and only the full run's `turn audit passed` counts. A run with `--only` ends `turn audit partial`, never `passed`.

**States.** The audit sweeps each turn group at 0.3° (order) and 0.05° (continuity), plus 2000 random states and every order-change event ± 0.001°. Motion clips (`clips: [{ name, at(t), from, to, frame }]`) are walked frame by frame: a gesture, a grasp, a break-apart. Free and hinge groups are proved only on the states you give, so give every clip, at several turn angles.

**Lines.** `separation`, `unseparated`, `cycles`, `forced`, `swaps`, `layers`, `rigid`, `swept`, `posed`, `folds`, `self`, `crowding`, `endon`, `coverage`, `depth`, `continuity`. `3d.md`, section 13, has each line's failure and fix. A planted fault fails each line: a sunk pad (`separation`), a pinwheel of three tilted sticks (`cycles`), a fibre bent in a vertical plane (`folds`), a tube at 30° (`endon`), a rib row that stops (`continuity`).

**Browser checks** (each runs its control first; a control that doesn't fail means the check is broken):
- `--order`: the recorded shapes in a WebGL z-buffer against the DOM's fills: 0 live pairs with 4+ interior pixels at every angle and clip state.
- `--pops`: first difference at every degree (θ → θ + 0.02°) and second difference at 0.1° over a full turn: 0 surviving regions, 0 cores of 6+ px. Read the 1–5 px cores it lists.
- `--lines`: 0 floating ends at 24 angles.
- `--perf`: the budgets in `3d.md`, section 15, at 1440 and at 390 with 4× CPU.

**Orbit sheets.** 0–345° at 15°, then 0.1° steps across the three largest order changes (the audit lists them under "order changes"), then every clip slowed (`["slow", 0.05]`). Read them for spider angles (a hand that reads as legs from some side), parts that vanish behind others, and lines that end on another part's outline.

### Checklist for 3D figures
- [ ] `turn audit passed` with the full state set (not `--quick`, not `--only`), clips included.
- [ ] `--order` on `--states` for every hinge and clip, `--pops` on a 0.02° path for every hinge (`3d.md`, section 14).
- [ ] `turn-check` order, pops and lines at 0, fidelity passed, every control failing as designed.
- [ ] Orbit sheets read at 15°; 8× crops of every knuckle, crossing and texture end at 8 angles, light and dark.
- [ ] Materials read as metal on the light page: highlight bands move with the turn, nothing pops at a tone crossfade.
- [ ] Every shader anchored to its group follows it at 0.1° steps; world effects don't turn; ink stops at occluding strokes (8× crop where a cover mask meets a part).
- [ ] Budgets measured at 1440 and 390 × 4, with the half-rate lever if a dense figure needs it.

## Close-ups
The owner found the Raptor's faults on a close-up of the light theme: cables over the main duct, a stepped bell, bolt rows that stopped dead. Shoot that close-up yourself, everywhere, before anyone else does.

1. **Build both themes** (`node build.mjs` and `node build.mjs --light`) and list the junctions: every flange, tee, elbow, ball joint, clamp, valve, bellows, band, saddle pad and crossing, every curved body (bells, throats, domes, cones, valve bulges, balls), every band joint of a body cut with `bandsOf`, and every place a texture ends. On flat parts too: every seam, weld, stiffener and trim line on a box face (does it reach both corners?), every row of ticks or marks on a plate (is it on a ruler strip?), every rib row's two ends along its axis, every textured hose at a chunk joint and at its ferrules, every rod that slides (at rest, mid-stroke and at full stroke), and every window or screen with text or shader content in it.
2. **Shoot each area at 4× in both themes, assembled.** `--zoom x,y,w,h` takes a region in card px and scales it by `--zoom-scale` (4):
   ```sh
   node scripts/capture.mjs page.html --out shots/joint-dark.png --zoom 200,80,220,170
   node scripts/capture.mjs page-light.html --out shots/joint-light.png --zoom 200,80,220,170
   ```
3. **And apart**, if the figure comes apart. Drive it there, wait for rest, then shoot a region at scale 4. The Raptor toggles apart on Space and its readout starts `apart ·` only once every spring has arrived, so `apart.json` is
   ```json
   [["key", " "], ["until", "document.querySelector('[data-readout]').textContent.startsWith('apart')"], ["wait", 400],
    ["shot", "shots/apart-joint.png", null, 4, [200, 80, 220, 170]]]
   ```
   run with `node scripts/drive.mjs page.html --actions apart.json`, then again on `page-light.html`.
4. **Look at each PNG for these faults, in this order:** a line that runs through or into something; a crossing whose nearer line is drawn behind; a bend, end or clamp sitting on another line's outline; a flange standing off its port or sunk into it; tone blocks stepping across a curved surface, scallops or a pale wedge; a band ending on a straight cut; a row of ribs, dots or rule lines stopping in the middle of a surface; a hairline across a tube's shine or shade at a chunk joint; a seam notching the outline; a seam, stiffener or rib that stops short of the edge it should reach; a short mark on a top that touches nothing; a hose texture with straight hard sides, or one that restarts or is cut at a chunk joint; a rod whose outline breaks while it moves; text that crosses its window; shader content that spills past an opening's rim. Compare with the previous round's shot of the same region.
5. **Shaders:** the same regions during the effect, plus contact sheets slowed with `["slow", 0.05]` (`webgl.md`, section 9).

**Detail pass.** Last, sweep at 4× in a grid of crops (the subject and mechanism for a Figure, the whole drawing for a Hero or an Epic; `SKILL.md`, workflow step 1) and look for any face, corner or stretch of base with nothing true on it. Every figure is highly detailed (`SKILL.md`, "Every creation is highly detailed"): add the real parts that belong there (screws, seams, bevels, label plates, scales, the brackets that hold things), never decoration that belongs to no part.

## Light-theme shaders
Light is the shipping theme, and on paper a glow is ink. Every light conversion so far failed review first on something a picture of one frame doesn't show: ink that rose while the light fell, a pale flash before the glow, a salmon stage, a fringe beside a stroke. So measure the ink as well as looking at it (`webgl.md`, section 8).

**Ink per frame.** Save this as `ink.js` and pass it with `--preload`. For every frame the page draws, it logs the mean ink one canvas region lays on white, `255 − lum(rgb + 255 − a)`, read back in the same task as the draw:
```js
const get = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (kind, ...rest) {
  const gl = get.call(this, kind, ...rest);
  if (gl && String(kind).startsWith("webgl") && !this.__gl) {
    this.__gl = gl;
    for (const name of ["drawArrays", "clear"]) {
      const call = gl[name].bind(gl);
      gl[name] = (...args) => { gl.__drew = true; return call(...args); };
    }
  }
  return gl;
};
const raf = window.requestAnimationFrame.bind(window);
window.__ink = [];
window.requestAnimationFrame = (callback) => raf((now) => {
  callback(now);
  const canvas = document.querySelector(window.__inkCanvas || "canvas");
  const gl = canvas && canvas.__gl;
  if (!gl || !gl.__drew) return;
  gl.__drew = false;
  const [x, y, w, h] = window.__inkBox || [0, 0, canvas.width, canvas.height];
  const px = new Uint8Array(w * h * 4);
  gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
  let ink = 0;
  for (let i = 0; i < px.length; i += 4) {
    const bare = 255 - px[i + 3];
    ink += 255 - (0.2126 * (px[i] + bare) + 0.7152 * (px[i + 1] + bare) + 0.0722 * (px[i + 2] + bare));
  }
  window.__ink.push(Math.round((ink / (w * h)) * 100) / 100);
});
```
- **Choose what to measure.** Before the transition, set `window.__inkCanvas` (a selector) and `window.__inkBox` (`[x, y, w, h]` in device px of the canvas, measured from its bottom-left; scale CSS px by `canvas.width / canvas.clientWidth`) with an `eval`. Read the rows afterwards with `["eval", "window.__ink.join(' ')"]`.
- **Do it once per region that matters:** a cavity, the core, an area only the fallback covers. Run it slowed, across first light (`--preload` a second file with `window.__driveSpeed=0.04`), every phase change and the fade-out.
- **What to look for.** Ink follows the physical quantity monotonically: up as the light comes in, down as it goes. There is no hump at a hand-off and no dip at first light.
- **What it caught.** The afterburner's cavity peaked at 3600 on the cut before its fix; afterwards it fell monotonically from 1894 to 155. Those rows are in that figure's own units, a sum over its window region rather than this hook's mean. On the ripple tank's light page the hook reads 14.2 at rest and falls as the lamp comes in, because that page's light brightens the paper.

**Colour along the ramp.** For anything that heats or cools, `eval` a pixel read at the centre and the rim at several temperatures (or several temperatures' worth of a slowed run) and write them down. The order must be dull red → orange → yellow → near-white, with the rim cooler than the centre and no salmon or grey stage on the way.

**Cross-sections.** Read one row of ink across a flame, a jet or a stream. It peaks on the axis and falls to translucent edges. An edge-heavy profile is a hollow, outlined flame.

**Strokes at 8–16×.** Crop every place where ink meets an SVG stroke (cut faces, rims, mouths, creases, a canvas edge with a body entering) at 8–16× in light. Look for a pale gap between the ink and the line, ink over the line, or a stroke the ink cleared.

**Both paths, and nested.** The dark build should match its baseline, because every light change is gated on `uLight`. Render a dark plate on a light page and a light plate on its own, each with and without WebGL: a `:not([data-theme="dark"])` rule can reach through the page into a dark plate.

## The review checklist
**It is an object**
- [ ] At a glance it reads as one real, well-made thing, and you can name it.
- [ ] Its mechanism maps to the concept: what moves and what that means can be said in one sentence.
- [ ] It isn't a stack of abstract plates and isn't one plain slab.

**Parts and volume**
- [ ] 30–60 solids for an inline figure; a hero subject that really has more parts can go far past that (the Raptor has about 270). Every part rests on another and has a job.
- [ ] Sides shaded and tops lighter; bevels on the main slabs; a halo under the base and under lifted parts.
- [ ] Small details belong to parts: screws at the corners of plates, grooves, rulers, knurls, label plates.

**Lines**
- [ ] At 4×, every line ends on a face, another line or a terminal dot. Nothing stops in the air.
- [ ] No leader lines, balloons, dimension arrows or floating labels; at most one engraved label.
- [ ] Painter's order is right everywhere: nothing behind shows through, springs and frames are split.
- [ ] **Audit zero.** With pipes, cables or round parts in the figure, `node build.mjs --audit` ends `audit passed`: zero on every failing line, assembled, apart and in every frame of the replayed explode (opening, closing and reversing). No interlock was hidden with a key bias, and every excused pair (`allow`, `crowd`) has been looked at at 8×.
- [ ] No pipe or cable passes through another pipe, cable or solid, and every crossing is drawn in depth order: the nearer line on top, crossing on straights, well apart in depth.
- [ ] No line runs across the subject's face where it could run beside the structure; no bend pinches to a V on screen; no bend, end or clamp lands on another line's outline, assembled or apart.
- [ ] Every flange sits on its port (on a saddle pad if the port is curved); every collar, clamp, band, bellows and valve gaps the pipe it wraps; every valve and bellows sits on a straight; no route doubles back on screen; every end, apart too, ends on a flange, coupling, connector or terminal dot.
- [ ] **Junction close-ups.** Every junction area shot at 4× in both themes, assembled and apart (see "Close-ups"), and each PNG read.
- [ ] **No staircase.** Every curved surface is smooth-shaded at 4×: no stepped tone blocks, no scalloped terraces, no pale wedge, no band that ends on a straight cut, no kink at a band joint; waisted bodies follow their true outline; seams meet the outline without a nick.
- [ ] **Line ends zero.** `node scripts/lines.mjs` prints `line ends: 0` on both themes and in every resting pose; every `free` end is a rule line standing for text, a needle tail or a pointer tip.
- [ ] **Shapes are true.** No stand-in shapes: what is recorded is what is drawn, including rods that slide.
- [ ] **No abrupt texture ends.** Every row of ribs, bolt dots, rule lines or grooves ends on a real edge or fades out; none stops at full strength in the middle of a surface, flat faces included. Seams on boxes wrap their corners, rib rows end on their seams, ticks sit on a ruler strip, and a texture on a hose follows its cross-section and runs through every chunk joint. Pipe stripes run onto their end caps and show no hairline at any chunk joint, also on a strongly curved tapered tube (a volute).

**Tone and colour**
- [ ] Base `lo`, structure `mid`, subject `hi`, exactly one `lit` accent.
- [ ] Monochrome unless colour is the subject.

**Card and words**
- [ ] `Fig n` / title / hint / live readout are in the corners, with legend keys and a caption under the card.
- [ ] Every number is true and comes from the model. The readout changes as things move, and its words and numbers come from the same instant: a phase word from the model next to a number from a lagging gauge reads `relief 85 bar` before the valve has cracked.
- [ ] Text the machine displays fits its window at its widest string and is clipped to it.

**Motion and access**
- [ ] Eased, calm, no snapping; an idle tour; pauses on interaction.
- [ ] Stops offscreen; still under `--reduced`; no console errors (including React key warnings).
- [ ] Keyboard works, the focus ring shows, and `aria-valuetext` reads the state in words.

**Shaders (if the figure has one; `references/webgl.md`)**
- [ ] A contact sheet across every transition (start, each phase change, steady, stop), slowed with `["slow", 0.05]` (or `--preload` with `window.__driveSpeed=0.04` for an effect that starts on load) so every frame of each crossfade shows: no sudden colour switch, no one-frame flash, nothing cut out, and brightness never rises during a fade-out.
- [ ] 2× and 4× crops where the effect meets the SVG: no seams, hard edges, banding, dark collars or double lines; things on a plane register with that plane's lines.
- [ ] The canvas's outer rows and columns read alpha 0 in every phase.
- [ ] Without WebGL (`--no-webgl`) the SVG alone still reads; under reduced motion you get one still, representative frame; the loop stops when idle and offscreen.
- [ ] **Light theme** (`webgl.md`, section 8; "Light-theme shaders" above):
  - Glows read through saturation, with a saturated body, a pale core and a coloured rim, never as dark or brown ink.
  - What the light falls on is tinted, and the cavity that holds the light is a darker physical ground.
  - Hot bodies run in blackbody order with no salmon stage.
  - Smoke and steam are light grey.
  - There are no halos in empty air.
  - Ink per frame follows the light monotonically through first light, every hand-off and the fade-out.
  - Ink stops at each stroke's inner edge at 8–16×.
  - The fallback shows the same inks and ground, and hands over to the shader without a dip.
  - Dark is unchanged, and a dark plate on a light page still works.

**Composition**
- [ ] Centred, filling about 55–65% of the card; it doesn't touch the corner labels.
- [ ] Still readable at 390 px with no sideways scroll, with a part selected and during a burn, not only at rest: the longest readout must not run under the controls.

## Common faults and fixes
| You see | Fix |
|---|---|
| A part shows through something in front of it | It is painted too late. Move it earlier, or split the front part. |
| An ellipse shows across a post where it meets a face | A full `ring` drawn over a cylinder that continues shows its back half. Use `sideArc` (front half only), or let the cylinder's own outline make the edge. |
| A post looks pasted onto the block it passes through | Draw it in two segments, below and above, with the block painted between them. |
| A crease or bevel stops short of a corner | Use `bevel` on `slabOf`/`cylinder`. It already ends on the silhouette. Don't hand-draw edge lines. |
| Flat, dead sides | You passed `flat`, or the solid has height 0. Give it thickness. |
| Too sparse | Add structure, not texture: feet, rails with rulers and end stops, a label plate, screws, a knob, a second stage. |
| Too busy | Drop texture-like repeats and tone grooves down to `faint`; keep one lit accent. |
| The figure is tiny in the card | Fit with a smaller `pad`, or tighten the points passed to `fitProjection`. |
| It jumps when a tab regains focus | Clamp `dt` to 1/30 s. |
| Key warnings only on the big page | See `react.md`: key the roots of element props. |
| A shader colour jumps at a phase change | Give colour its own slow envelope, and drive it from the physical quantity, not from a phase flag. See `webgl.md`, section 7. |
| A straight edge in the glow, often at the canvas border | A term isn't zero at a boundary. Window it to zero before every `if`, bound and canvas edge. |
| A glow runs through the floor or over a column | It is in screen space. Rebuild it in world space with the ray and the ground hit, and give simple parts to the shader as occluders. |
| A dark collar under a glowing rim | The rim is normal-blended over the shader. Move it to an overlay SVG with `mix-blend-mode: screen`. |
| Flat bands in soft glows | Add gated triangular dither after `tonemap`. |
| In light, a glow reads as dark red or brown smoke | It is the dark colour darkened, with alpha set to brightness. Solve the ink from a paper ramp with a saturated body, a pale core and an alpha floor (`webgl.md`, section 8.1). |
| In light, first light goes pale and then dark, or a fade-out greys or gets heavier | Per-term fades, an alpha-only fade, or a hue that reddens at fixed alpha. Fade the finished ink once, dim energy and alpha together, and take the hue at steady brightness (`webgl.md`, section 8.2). |
| In light, a pale fringe or white seam along an edge | The ink stops short of the stroke, or clears a crease nobody strokes. End it at the stroke's inner edge (0.3 vb) and draw the crease in ink (`webgl.md`, section 8.6). |
| A dark plate's fallback vanishes on a light page | A `.iso:not([data-theme="dark"])` rule matched through the page. Put the dark rule last, or key on the plate's own theme (`webgl.md`, section 8.5). |
| A pipe or cable looks pasted on or cuts through a part | Its pieces are in one layer. Depth-sort each `tubePieces` chunk by `mid`, and use shorter pieces where it weaves. If it really passes through the part, reroute it; `--audit` lists every case. |
| Cables or sense lines fan across the subject's face and over a main duct | They were routed point to point. Re-route along the structure: up beside a pipe, round the body in clamped lanes, round the side to a far port (`craft.md`, "Choosing waypoints"). |
| Fixing one crossing's order breaks another, or the apart pose | Keys tuned by hand. Record shapes, let `A.settle` set the keys, and fix whatever it reports as interlocked in the geometry. |
| A collar, band or clamp hides the pipe's near side, or the pipe paints over the collar's face | The pipe runs through it as one chunk. Gap the pipe at the collar (`gaps`), so a piece ends on each face. |
| A ring on a body shows its back half over the body | The body is one long band under the ring. Split it at the ring. |
| A valve or bellows cuts into its pipe at the ends | It sits on a bend. Move it to a straight at least its own length plus both fillet cuts. |
| Stepped tone blocks on a bell, dome or cone | Facet shading. Mark the curved knots smooth (`[s, r, 1]`, shaded smoothly by default) or pass `smooth: true`; cut a body into bands with `G.bandsOf`. |
| Tone bands on a throat, cone or puck stall and lurch in scallops | A smoothed profile of hand-typed knots: its slope plateaus at each knot. Write the profile as an analytic `r(s)` and pass its derivative as `smooth: { slope }` (`wobble` lists them). |
| Hairline seams inside a smooth-shaded part | The tone sheets abut. Use the kit's `solidSvg` with `seamless` paths (smooth lathes and `sphereOf` set it) and `ISO_CSS`. |
| A row of ribs or bolt dots stops in the middle of a surface | A hard `least` cut-off. Use `fade: [lo, hi]` on `ribsOf` or `dotsOf`. |
| A pale wedge with straight edges on a smooth-shaded cone or bell | Bare fill between tone pieces: pieces of one tone wound in opposite directions cancel under the nonzero rule, or a band ended without tapering. The kit orients every tone polygon and tapers bands; if you build tone paths yourself, orient them. |
| A narrowing body looks like a straight cone with a pale strip | It was hulled. Let `lathe` pick `fill: "auto"` (or pass `"surface"`). |
| A hairline crosses a tube's shine or shade band | Body and stripe cut on the same line at a chunk joint. `tubePieces` runs the stripes past the body; keep that if you change it. |
| The outline is dashed where seams meet it | Seams drawn after the solid. Pass them as `inner`. |
| A pipe's stripes stop short of its end | An end without a cap. `tubePieces` caps every end by default; don't pass `caps: false` for an end that shows. |
| Parts slide along their mount while the figure comes apart | They move with one assembly's vector on another's spring. Give the item that assembly's `ride`. |
| A long turned body has a kinked outline at band joints | Build it with `G.bandsOf` and draw the sides once with its `sidesOf` (`kit.md`, section 10). |
| A texture line stops in the middle of a face | End it on a real edge, or draw it with `fadeLineSvg(points, { fade })` so it ramps out. `scripts/lines.mjs` lists every one. |
| A seam on a box stops short of its corners | `k.sideSeam(plan, z, P, steps)` as `inner`. |
| A hose braid or corrugation with straight hard sides, or broken at chunk joints | `tubePieces(…, { rings: { pitch, twist, cross: true } })`. |
| A rod's outline notched while it slides | One rod from `G.rodOut`, clipped at the gland, recorded with `A.slide`; never overlapping copies. |
| LCD or counter digits cross the window | Fit the type to the window's height and widest string, and clip it. |
| Stray triangles of outline across a valve or bulge | An old kit joined a broken silhouette; the kit now falls back to the hull when a side's silhouette has gaps. Rebuild with the current `lathe.mjs`. |

## references/walkthrough.md

# Walkthrough: the test rig (Fig 6)

How `examples/test-rig/` was designed. It is a figure for a "Stretch" section explaining how a glass button responds to touch: a press swells it, pulling makes it lean toward the finger (with less give the further you pull), and letting go springs it back past rest once.

## 1. Truth
The behaviour was copied into a pure model, `rig.ts`, from the library source:
- The rest size is 120 × 32 (`HALF = [60, 16]`).
- A press swells it by 8%, at most 6 px a side.
- The lean is a rubber band: `give · (1 − 1/(1 + 0.55·d/give))`, with `give` = 14 px.
- There are three springs: press 700/32, hold 540/40 and release 320/16, as stiffness/drag.
- The capsule stretches along the lean by at most 8% and thins across.

`step(member, held, pull, dt)` advances it and `poseOf(member)` returns the shape. The readout, the aria text, the caption numbers and every moving part read from this one model.

## 2. Object
"A test rig." A real lab would hold a button in a sprung fixture and measure it with a probe and a dial gauge. Each part carries meaning:
- **The glass capsule** is the subject. It is lit while held and its shape is rebuilt every frame from the pose.
- **A carriage on two guide rods with four coil springs** shows that the glass is springy and returns. The springs compress and extend as it leans.
- **A finger probe on a rail behind it** is the pointer. It comes down to press, then slides along the rail to pull. A ruler and a rider on the rail show how far it has pulled.
- **A dial gauge with a plunger touching the carriage** reads the lean. Its needle turns 20 px per revolution, with a mark at `give`.
- **A base plate on feet** carries a frame of four bars with a ruler and a zero mark, screws, a label plate, a floor grid and bores for the rods.

## 3. Parts list (53 solids)
| Group | Parts |
|---|---|
| Base | plate, 4 feet (flange and foot), groove, 4 corner screws with rings, label plate with screws and rule |
| Rail | 2 posts on feet, the beam with a ruler (minor every 5, major every 25), 2 end stops, screws, a rider |
| Probe | block, thumb knob with knurl, pointer, index line, arm, housing with cap, collar, seams, lock screw, stem, collar, pad |
| Frame | back, left, right and front bars with screws, floor with grid, rod bores and ends, ticks with a zero mark |
| Mechanism | 2 rods, 4 coils (split back and front), the carriage plate with a seat outline, 4 bushings, rivets, a contact block, a plunger rod with a collar |
| Dial | stand with screws, sleeve, clamp, lug, puck, knurled band, bezel and face rings, ticks, give marks, crown, a "px" engraving, the needle |
| Subject | the glass capsule (fill, shades, top, crease, bevel, inner rim, shine, edge) and the dotted rest outline |

## 4. Camera and layout (`view.ts`)
- **Camera.** Azimuth 57°, so the long front of the frame faces the viewer. The viewBox is 600 × 340, fitted to the base and the top of the rail with a pad of 34.
- **Layout.** The rig is laid out around the origin. The glass is centred at (0, 0) on the carriage top, the rods run along x at y = ±18, the dial stands at x = 131 and the rail sits behind at y = −66, z = 50.
- **Helpers.** `at(point)` projects a point and `shift(x, z)` moves things along x and z.

## 5. Geometry (`geometry.ts`, `pose.ts`)
- `geometry.ts` computes every static part once: a `solid(plan, z, h, steps, bevel)` wrapper over `slabOf`, a `cylinder`, `knurl`, `sideArc`, `faceRing` and ruler helpers.
- `pose.ts` has `frameOf(pose, …)`, which rebuilds only what changes: the capsule's paths, the 4 coils, the carriage and probe transforms, the needle and the pull wire.

## 6. Painter's order (`live.tsx`)
1. Base.
2. Rail, with the rider and the probe block.
3. Back frame bars.
4. Back coil halves.
5. Rods.
6. Left coils.
7. Carriage.
8. Glass.
9. Rest outline.
10. Pull wire.
11. Right-back coils.
12. Plunger.
13. Right-front coils.
14. Front frame bars.
15. Dial with needle.
16. Finger.
17. Arm.

The frame and the springs are split so the carriage sits *inside* them.

## 7. Live behaviour (`live.tsx`)
- **One loop.** It reads the scripted tour or the user's hand, eases the finger (τ 0.09 s) and the lift, steps the spring model, then writes every changed attribute through refs and says the readout.
- **Idle tour.** It starts after 1.4 s in view and loops every 7.6 s: rest, press, pull 80, release, settle. It pauses on hover, focus or press and resumes 3.6 s after the last input.
- **Pointer.** Hover moves the finger along the rail; press-and-drag presses and pulls.
- **Keyboard.** Space presses or lets go, arrows move and pull (Shift for bigger steps), Home and End go to the ends, and Escape releases.
- **Accessibility.** `role="slider"`, and `aria-valuetext` such as "Pressed: 129.6 by 34.6 pixels, pulled 80.0 px, leaning 10.6 px".
- **Reduced motion.** The springs become critically damped: the glass still swells, but it never leans or bounces.

## 8. Card
- **Corners.** "Fig 6" · "Press and pull" · hint "Press, then pull ← →" · readout "120.0 × 32.0 · lean 0.0 px".
- **Legend keys.** The glass while it is held (raised) · where it rests (dotted) · the pull, from where I pressed to the finger (edge).
- **Caption.** States the rig and the true numbers.

## references/webgl.md

# WebGL inside the drawing

How to put a shader effect (fire, water, light, glow) inside an isometric figure so it reads as part of the same object, seen through the same camera. Two shipped examples do this: `examples/raptor-engine/` (a plume, shock, splash and steam under a rocket engine) and `examples/ripple-tank/` (caustics, shadows and a rippled water surface). Read their `live.js` next to this page.

The user loved the first shader figure, but noticed that the colour changed instantly between its phases, as if the effect had been cut. So the bar is: **nothing pops, nothing is cut, and everything sits in the drawing's perspective.** Every rule below came from a bug that broke that bar.

## Contents
1. When a shader earns its place
2. The layer stack
3. The camera contract
4. Ray-marching in the iso camera
5. Registering shader content to the SVG
6. API and a minimal page
7. Craft rules, each with the bug that taught it
8. Light theme (8b. Shaders on a turning group)
9. Verify
10. Performance

## 1. When a shader earns its place

Use a shader for things lines can't draw: emitted light and glow, fire and exhaust, water surfaces, caustics, soft shadows from a real lamp, smoke and steam. These are continuous fields, and they're the subject or what the subject does.

Don't use one for:
- **Solids.** Parts, edges, ticks and screws stay SVG from the kit. The shader never draws a part that the SVG could draw.
- **Decoration.** Shimmer, background gradients, bloom on every edge, or noise added to make the figure look "rich". If you could delete the effect and the explanation still holds, delete it.
- **Something the SVG already shows well.** A glowing LED is a `lit` dot, not a shader.

The SVG must still read on its own. The Raptor without WebGL still shows the engine, with a rim light and a CSS deck glow. The ripple tank draws its crests and nodal lines as SVG lines (`patternOf` in `model.mjs`), clipped to the drawn outline of the tank's shadow on the paper, and hides them with `.rt-stage[data-gl] .rt-fallback { display: none }` once WebGL is running.

## 2. The layer stack

A shader can't be depth-tested against SVG, so you order layers instead. The basic stack is three layers in one `position: relative` stage, all with the same viewBox:

```
back SVG     what the effect covers        (k.figureSvg: role="img" and the label)
canvas       the effect                     (position:absolute; inset:0; pointer-events:none)
front SVG    what covers the effect        (aria-hidden)
```

For every part, ask one question: **is it ever covered by the effect, or does it cover the effect?** Parts that are covered go in the back SVG, and parts that cover go in the front SVG. If a part does both, you have three choices:

- **Split the art.** Cut the part into pieces and send each piece to the layer, and the slot in that layer, where it belongs. The ripple tank's motor cable is three groups of `tubePieces`: the run over the bridge goes in the front SVG before the bar, the run along the near rim goes in the front SVG after the near caps, and the run down the left leg to the control box goes in the middle SVG.
- **Model it in the shader.** If the part is a simple shape (box, cylinder, torus) and stays in the back SVG, give the shader that shape as an occluder. The Raptor's columns are opaque boxes (`columnNear`), so every air integral starts at `max(tGround, tColumn)`. The deluge ring is a torus inside `groundHit`, so the splash lights its inner side and leaves its outer side dark.
- **Split the effect.** When the effect passes in front of front-SVG parts, add a second canvas above the front SVG. An analytic proxy of the occluding part decides, for each sample, whether it goes to the back canvas (weight `1 − f`) or the front canvas (weight `f`). Each sample is drawn exactly once, so there is no seam. Make the proxy generous: outside the real silhouette both layers look the same.

### Worked case: the Raptor
```
back SVG    deck, feet, flame cone, deluge ring, columns, ladder, cable tray
canvas      .rp-plume: plume volume, shock (back half), flash, splash, ground ring, steam behind the engine
front SVG   every engine item (depth-sorted .it groups), pipes, harness, crosshead
glow SVG    .rp-glow: the two nozzle-lip rims, screen-blended so they can only add light
canvas      .rp-veil: steam and shock samples that lie in front of the engine
div         .rp-flash: a CSS radial gradient (closest-side) for the ignition flash
```
The engine proxy is `engineR(z)`, a radius function of the bell, chamber and pumps. `inFront(p, V)` says whether sample `p` lies between the engine and the viewer. `side(front, veil)` sends the sample to one canvas or the other.

### Worked case: the ripple tank
```
back SVG    base plate, feet, ruler, label plate, paper sheet with tape, lamp cable over the base edge, SVG fallback pattern
canvas      .rt-paper-gl: lamp light on the paper and base, caustics, shadows of the legs, post, bridge, motor, control box and foam beach, refraction
middle SVG  post, arm, lamp and its cables, control box, motor cable down the left leg in clips, legs with levelling screws,
            far rails, three corner posts, far glass, foam beach, plain-water fill
canvas      .rt-water-gl: window reflection and room-light glints, meniscus tilt at the walls, dipper contact rings,
            the water band behind the near glass, glass tint, the lamp's beam (dark only)
front SVG   far caps, motor cable over the bridge, far pillar and spring, bar, dippers, motor, near spring, near rails,
            glass bottom, water lines, near glass, near caps, motor cable on the rim, near corner post, near pillar, beam
```
Two canvases sit at two depths because there are two effects at two heights: light on the paper (under the legs), and the water surface (inside the tank). The control box stands on the base beside the tank, below the water canvas, so it lives in the middle SVG; the near corner post is painted after the caps it stands on.

CSS for the stack (adapted from `ripple.css`):
```css
.fx-stage { position: relative; touch-action: pan-y; }
.fx-stage > svg, .fx-stage > canvas { display: block; width: 100%; height: auto; }
.fx-stage > .fx-layer { position: absolute; inset: 0; height: 100% !important; pointer-events: none; }
.iso:not([data-theme="light"]) .fx-gl { mix-blend-mode: screen; }
```
Screen blending can only brighten. A canvas that must also darken what it covers (the ripple tank's water, which tints the paper seen through it) stays on normal premultiplied blending in dark too: only `.rt-paper-gl` is screen-blended.
If the front SVG must take hover (the Raptor's parts do), give it `pointer-events: none` and its items `pointer-events: visiblePainted`, so the canvas and empty areas never catch the pointer.

## 3. The camera contract

`kit/gl.mjs` prepends `GLSL_ISO` to your fragment. It declares the uniforms and functions that make the shader see the same camera as `iso()`:

| Name | What it is |
|---|---|
| `uCanvas` | Canvas size in device pixels. |
| `uView` | The viewBox size `[W, H]`. |
| `uOrigin`, `uK` | The projection origin and the screen units per world unit (`glCamera(P).k`, equal to `scaleOf(P)`). |
| `uCam` | `(sinA, cosA, sinE, cosE)` of the azimuth and elevation. |
| `uTime` | The time you pass to `draw`. |
| `viewOf(gl_FragCoord.xy)` | Device pixel to viewBox point (it flips y). |
| `project(vec3 p)` | World to viewBox, identical to the kit's `iso()`. |
| `towardViewer()` | The unit 3D view direction, `(cosA·cosE, sinA·cosE, sinE)`. Every `p + t·towardViewer()` lands on the same pixel. Not the same as the kit's JS `towardViewer(P)`, which returns the 2D floor direction. |
| `rayOf(vb)` | The world point under `vb` on the vertical plane through the origin that faces the camera, so `rayOf(vb) + t·towardViewer()` is the pixel's ray. |
| `pixelOf()` | World units per canvas pixel across the screen, `(uView.x / uCanvas.x) / uK`. |
| `lightDir()` | The kit's light, equal to `lightOf(P)`, so shader shading agrees with the SVG tones. |
| `onFloor(vb, z)` | The world point on the horizontal plane at height `z` under this pixel. |
| `onWallX(vb, x)`, `onWallY(vb, y)` | The world point on the vertical plane `x = const` or `y = const` under this pixel. |
| `hash12`, `hash13`, `noise2`, `noise3`, `fbm2`, `fbm3` | Hashes and value noise, plus 4-octave fbm. |
| `window(x, a, b, soft)` | 1 inside `[a + soft, b − soft]` and 0 outside `[a − soft, b + soft]`: a smoothstep `2·soft` wide centred on each bound, so it is 0.5 at `a` and `b`. |
| `tonemap(c, exposure)` | `1 − exp(−c·exposure)`. Use it instead of `clamp`. |

`onFloor` and the wall functions round-trip exactly with `iso()`: `project(onFloor(vb, z)) == vb`. That is what lets shader content register with SVG lines.

**Why world space.** The figure's perspective is the projection. If you draw an effect in screen space (an ellipse sized by eye, a glow column in pixels), it can't agree with the drawing. It floats above the floor, runs through the deck, or keeps the same shape when the camera changes. Build everything from world points and world sizes. Screen sizes come only from world units times `uK`.

Antialiasing in world units: `pixelOf()` is world units per device pixel across the screen. On the floor in the depth direction, divide it by `uCam.z` (sinE), and up a wall by `uCam.w` (cosE). Soften registered edges by about `0.7 * px`, so they come out one pixel soft: crisp like the SVG, but not aliased. Where refraction stretches the image, a fixed softness stair-steps; the ripple tank enables `GL_OES_standard_derivatives` and widens its shadow penumbra by `length(fwidth(q.xy)) / length(fwidth(rest))`, the shifted point's footprint over the unshifted one, which is exactly 1 at rest, with an `#ifdef GL_OES_standard_derivatives` fallback.

## 4. Ray-marching in the iso camera

The camera is orthographic with uniform scale: both rows of the projection are orthonormal. Three things follow.

- **Every pixel is a straight ray** `p(t) = p0 + t·V`, where `V = towardViewer()` and `p0` is any world point under the pixel. Take `p0 = onFloor(vb, h)`, or the kit's `rayOf(vb)`, the point on the vertical plane through the origin that faces the camera (the Raptor's ray state starts there: `S.g0 = rayOf(vb)`). Larger `t` is nearer the viewer.
- **Distances to points are screen distances.** Screen distance divided by `uK` is the exact perpendicular distance from the pixel's ray to a world point. A spherical flash, shock shell or glow ball can be computed from that distance and still be world-correct. The closest-approach parameter is `tc = dot(C − p0, V)`.
- **Visible fraction behind an occluder.** If the first opaque surface along the ray is at `tLow`, a Gaussian centred at `tc` with width `sigma` is visible by `1/(1 + exp(2.41·(tLow − tc)/sigma))` (the Raptor's `beyond`). That gives a soft cut, but a geometric one.

To march a volume:
1. **Find the first opaque hit** along the ray: the floor, plus any heightfield the SVG draws. The Raptor steps 28 times from above the flame cone's tip down to the deck and then bisects 7 times on a `ground(r)` heightfield built from the same numbers as the SVG lathe profile. Only the part of the ray in front of the hit (`t > tLow`) is air.
2. **Bound the march** with a simple shape (the Raptor uses a radius-132 cylinder from the ground hit up to the exit plane). Window every term to exactly zero before that bound, so the bound is only an optimisation.
3. **Sample with dither.** Use `t = ta + (i + hash12(gl_FragCoord.xy))·dt`, so 28 samples don't band.
4. **Normalise each term by its chord**, so the centre pixel gets the intended brightness whatever the step count. For a vertical-axis volume `r² = u² + (t·cosE)²`, so a Gaussian of radius `σ` integrates to `σ√π / cosE` along the ray: weight each sample by `dt·cosE/(σ·1.772)`.
5. **Expect a uniform cylinder to look softer.** Its projected profile is sqrt-shaped, so it loses the defined edge a flat drawing had. In dark, add a limb shell (the Raptor's orange shear layer at `0.9·Re`) to bring the edge back. In light the same shell inks an outline round a hollow flame, so there you weight the body toward the axis instead (section 8.4).

Some terms don't need marching:
- A thin horizontal disc seen along the ray is an exact ellipse. The Raptor's Mach disks stay analytic: `u² + ((s0 − sc)/tanE)² < rm²`.
- A thin spherical shell integrated along a ray is limb-brightened: brightness is proportional to `rs·w / sqrt(rs² − d² + 2·rs·w)`. Square the normalised profile, or the inside reads as a milky bubble.

## 5. Registering shader content to the SVG

Anything that lies on a plane must line up exactly with that plane's SVG lines.
- **Same numbers.** Pass the SVG's plans and heights to the shader through `DATA` (the ripple tank passes `inside`, `outside`, `paper`, `base`, `round`, `z` and the plans of everything that casts a shadow), and hand them to `glLayer` once as `fixed` uniforms (its `FIXED`). Test against the same rounded rectangles with a signed-distance `sdRect`, so a lit paper corner ends on the drawn corner radius.
- **Same plane.** The ripple tank uses `onFloor(vb, uZ.x)` (the paper), `onFloor(vb, uZ.z)` (the water) and `onWallX(vb, uNear.x)` and `onWallY(vb, uNear.y)` (the two near glass walls) for the water band behind the glass. Never use screen offsets.
- **Seen through an opening, masked twice.** Content behind an opening in a nearer plane (a sight glass, a window, a recess, a gauge face under its bezel) is masked by its own plane (`onWallY(vb, back)`) and by the opening tested in the opening's plane (`onWallY(vb, face)`), the two soft masks multiplied together. Masked only in the back plane, a blind-test sight glass spilled its oil and glass about 0.4 px past the top rim onto the body face, and at the bottom the offset showed a strip of bare body inside the opening. Draw the recess's inner walls in the front SVG, shaded by facing (`craft.md`, section 10), so the offset between the two planes reads as wall, not as bare face.
- **Only the deviation moves.** If an effect displaces what you see (refraction through ripples), apply only the part that differs from the rest state. The ripple tank computes the sight line bent by the rippled surface and by flat water, and applies only the difference. At rest, the paper under the water lines up with the SVG paper. The shift is also faded to zero wherever the sight line to the paper passes within a few units of a leg (`legClear`), so refracted paper never slides out from behind a leg that the middle SVG draws over it.
- **Heightfields match the lathe.** The Raptor's `ground(r)` is the deck disc plus the flame-cone profile, with the same radii and heights as the `G.solidOf` profile in `build.mjs`. Flow behind the cone is hidden geometrically, and hard edges appear only where the SVG draws a real silhouette.

Check registration at 4× (section 9).

## 6. API and a minimal page

```js
import { GLSL_ISO, glCamera, glLayer, smooth, burst, settle } from "../../kit/gl.mjs";
import { kitScript, glScript } from "../../scripts/inline-kit.mjs";
```

| Function | What it does |
|---|---|
| `glCamera(P)` | `{ origin, k, cam }` for a kit projection. Works in Node and the browser. Put it in your page `DATA`. |
| `glLayer({ canvas, fragment, view, camera, uniforms = [], fixed = {}, extensions = [], maxRatio = 1.5, onError })` | Compiles `GLSL_ISO + fragment` on `canvas` with a premultiplied-alpha WebGL1 context. `view` is `[W, H]` of the viewBox, `camera` is `glCamera(P)` and `uniforms` lists the uniform names you send per frame. `fixed` is `{ name: value }` for uniforms set once at creation (geometry, theme constants). `extensions` lists GLSL extensions (`["GL_OES_standard_derivatives"]`); each one the context has is enabled and prepended as an `#extension` directive. Returns `{ gl, extensions, set(values), draw(values, time), clear() }`, where `extensions` lists the ones actually enabled, or `null` when WebGL is missing or the shader fails to compile (`onError`, which defaults to `console.error`, gets the log). |
| `set(values)` | Sets uniforms without drawing. |
| `draw(values, time)` | Resizes the canvas to its CSS box × `min(maxRatio, devicePixelRatio)`, sets the camera uniforms, sets `uTime = time`, sets each value in `values` and draws one full-screen triangle. |
| `clear()` | Clears to transparent, only if something was drawn. |
| `smooth(a, b, x)`, `burst(age, rise, fall)`, `settle(state, target, dt, seconds, calm)` | The JS envelopes every shader uniform should ride on (section 7 and `motion.md`). `settle` returns true while still moving. |
| `glScript()` | The source of `kit/gl.mjs` with `export` stripped, for inlining into a standalone page after `kitScript()`. Live code on that page uses `glLayer`, `smooth`, `burst` and `settle` directly and must not declare its own. |

A uniform value (in `fixed`, `set` or `draw`) can be a number (float), an array of length 2–4 (`vec2`–`vec4`), or an array or `Float32Array` longer than 4, sent with `uniform1fv` to a `uniform float name[N]`: a 24-sample history is one `uH[24]`. There are no ints, matrices or samplers through these calls. For more data than a uniform array holds, use a texture: a `sampler2D` reads unit 0 by default, so create and update it on `layer.gl`. The ripple tank packs a 32 × 2 history into an RGBA8 texture, 16 bits per value (`PACK`, `put`, `unpack`), one texture per layer because each layer has its own context. Unknown names and uniforms the compiler optimised away are skipped.

Your fragment declares its own extra uniforms and a `main()` that writes **premultiplied** `gl_FragColor`. The canvas must cover exactly the same box as the SVGs, with the same aspect ratio, because `viewOf` assumes one uniform scale.

A minimal page: a hot plate glowing on top of a square deck. The plate registers with an SVG ring of radius 40 (`k.ring(0, 0, 40, 0, P, 72)`), and the glow stops at the deck's rounded edge, since there is nothing beyond it to light.

`build.mjs`:
```js
const P = k.fitProjection(points, W, H, { pad: 28, azimuth: 40 });
const DECK = { x: -90, y: -90, w: 180, d: 180, r: 8 };
const DATA = { W, H, camera: glCamera(P), plateR: 40, deck: [DECK.w / 2, DECK.r] };
const backSvg = k.figureSvg({ width: W, height: H, label: LABEL, body: back });
const frontSvg = `<svg xmlns="http://www.w3.org/2000/svg" class="iso-svg fx-layer" viewBox="0 0 ${W} ${H}" aria-hidden="true">${front.join("")}</svg>`;
const stage = `<div class="fx-stage" tabindex="0">${backSvg}<canvas class="fx-layer fx-gl" aria-hidden="true"></canvas>${frontSvg}</div>`;
const page = k.pageHtml({ title, theme, body: `<style>${CSS}</style>${k.plateHtml({ ...card, body: stage })}`, script: kitScript() + glScript() + LIVE.replace("__DATA__", JSON.stringify(DATA)) });
```

`live.js`:
```js
const D = __DATA__;
const stage = document.querySelector(".fx-stage");
const still = matchMedia("(prefers-reduced-motion: reduce)");
const LIGHT = document.body.dataset.theme === "light" ? 1 : 0;
const FRAGMENT = `
uniform float uHeat;
uniform float uLight;
const float R=${D.plateR.toFixed(1)};
const vec2 DECK=vec2(${D.deck[0].toFixed(1)},${D.deck[1].toFixed(1)});
float deckDist(vec2 p){vec2 q=abs(p)-DECK.x+DECK.y;return length(max(q,0.))+min(max(q.x,q.y),0.)-DECK.y;}
void main(){
  vec2 vb=viewOf(gl_FragCoord.xy);
  float px=pixelOf()/uCam.z;
  vec3 p=onFloor(vb,0.);
  float r=length(p.xy);
  float disc=1.-smoothstep(R-.7*px,R+.7*px,r);
  float onDeck=1.-smoothstep(-.7*px,.7*px,deckDist(p.xy));
  float halo=exp(-pow(r/(R*1.6),2.))*onDeck;
  vec3 col=tonemap(vec3(1.,.42,.12)*(disc*.9+halo*.35)*uHeat,1.2);
  float a=max(col.r,max(col.g,col.b));
  vec4 dark=vec4(col,a);
  vec4 light=vec4(vec3(.62,.24,.08)*a*.8,a*.8);
  gl_FragColor=mix(dark,light,uLight);
}`;
const layer = glLayer({ canvas: stage.querySelector(".fx-gl"), fragment: FRAGMENT, view: [D.W, D.H], camera: D.camera, uniforms: ["uHeat"], fixed: { uLight: LIGHT } });
if (layer) stage.setAttribute("data-gl", "");
const heat = { x: 0, v: 0 };
let target = 1, clock = 0, last = 0, frame = 0, visible = false;
function tick(now) {
  const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;
  last = now;
  clock += dt;
  const moving = settle(heat, target, dt, 0.35, still.matches);
  if (layer) heat.x > 1e-3 ? layer.draw({ uHeat: heat.x }, still.matches ? 0.8 : clock) : layer.clear();
  frame = visible && moving ? requestAnimationFrame(tick) : 0;
}
const run = () => { if (!frame && visible) { last = 0; frame = requestAnimationFrame(tick); } };
new IntersectionObserver((entries) => { visible = entries[entries.length - 1].isIntersecting; if (visible) run(); }, { rootMargin: "120px 0px" }).observe(stage);
```
Without `uTime` animation this loop stops once the heat has settled. With an animated field (flicker, flow), keep running while the effect is above its activity threshold and visible, and stop otherwise.

## 7. Craft rules, each with the bug that taught it

### Nothing pops
Every uniform moves along a smooth envelope in time, with an attack and a decay. Never set a value to 1 in a single frame.

- **One-shot events use `burst`.** The first ignition flash went from 0 to full in one frame and whited out the card. Now every event (flash, shock, ground ring, shutdown pop, card shake) uses the kit's `burst(age, rise, fall)` from `kit/gl.mjs`, a smoothstep attack then an exponential decay:
  ```js
  const flash = calm ? 0 : burst(sinceMain, 0.08, 0.13);
  ```
  with a 50–90 ms attack (zero slope at onset). An alpha function `t·e^(−t)` ties the decay to the rise and stays white too long. Keep whole-card flashes low too: a flash plus a CSS veil at 0.9 opacity whited out the whole card for six frames, so the veil is now `flash × 0.32`.
- **Phase changes are crossfades driven by the physical quantity.** A separate preburner term that died while the main plume grew read as an instant colour switch. Now colour, brightness, raggedness, tongue length and splash are all functions of chamber pressure, and pressure moves on a spring toward each phase's target.
- **Colour gets its own slow envelope.** Even when driven by pressure, the orange-to-violet change crossed its smoothstep window in about 0.23 s on a fast spring, and brightness dipped 33% and came back inside the 130 ms flash: a cut hidden by a flash. The fix:
  - Hue has its own critically damped envelope, the kit's `settle`: `settle(burn.hue, lit && burn.pc > 0.2 ? 1 : 0, dt, lit ? 0.32 : 0.5, calm)`, sent as `uHue`.
  - Brightness is a separate `uGlow`, computed in JS so it falls monotonically from 1.15 to 1.0 as hue rises.
  - The pressure spring is critically damped (stiffness 20, drag 2√20), so it doesn't overshoot to 318 bar.
- **Brightness during a fade-out only falls.** On shutdown the plume flared back up as an orange flame, because `mix(a, b, hue)` with `b < a` rose again as hue decayed. Now the glow level freezes when shutdown starts and is then scaled only by quantities that fall.
- **Recheck neighbours when you change a spring.** Once the pressure spring was made critically damped, the preburner tongue nearly vanished. That leg needed its own stiffer critical spring (stiffness 64).
- **One-shot flags belong to the event.** Re-igniting within 2 s of a shutdown lost the next shutdown pop, because the pop's clock was only reset after a quiet period. Now a per-burn `popped` flag decides it. Resetting the clock in `ignite()` would have cut off a pop that was still fading.
- **Changes travel through the medium.** When the ripple tank's frequency changes, the new wavelength doesn't re-lay the whole field at once. Each frame the page records the source's true phase, ω, k, damping per mm and 1/group speed, keeps 32 samples 50 ms apart, and uploads them as a 32 × 2 texture to both shaders. Each point finds its age `r / groupSpeed` with one fixed-point step on its own history's group speed, and reads k and damping from that age, so the new wavelength spreads outward from the dippers the way it really would.
- **Keep the phase continuous through a change.** The first version used `θ = k(age)·r − φ(t)`. During a sweep the term `r·dk/dt` made far crests race and alias, and damping the amplitude while the frequency moved (`calm`) blanked the pattern: mean paper brightness dipped 17%. Now `θ = kE·r − uPhase + Ψ(age)`, where `Ψ` is the phase the source gained since that sample beyond what its ω predicts (`lag` in `packHistory`). At a fixed point the crests then move only as fast as ω changes, so no amplitude damping is needed, and a small motion-blur term (`gMove`, how far crests move per frame) softens the caustics during fast changes.
- **First light fades in, and in light it only brightens.** The ripple lamp fades in over 0.9 s on first view (`smooth(0, 1, (lampClock − 0.15) / 0.9)`). In dark the SVG plain-water fill fades out on the same value through a CSS variable (`--rt-lamp`). In light the first version started from bare paper, so the troughs' ink made the tank go darker as the lamp came on, and the plain-water fill darkened it further. Now the water fill is hidden whenever WebGL runs, and the paper is drawn from the first frame (lamp 0) at its unlit grey (`off`) and mixed toward lit ink by `uLight`, so first light only brightens. For an emitter on paper the same rule reads the other way round: its ink only grows as the light comes in, and the scene never goes pale first or dips dark (section 8.2).

### No hard spatial cut-offs
Every term reaches zero before any boundary: an `if`, a bounding shape, a plane, or the canvas edge. Use `if` only as an optimisation where the term is provably about zero.
- A bead term was cut off sharply at both ends of its range by an `if`. It now fades in and out with smoothstep.
- The plume had an `if` box, a `s < .01 continue` and a `step(ax, R0)` halo mask. All three showed as seams.
- A screen-space glow column ran from the nozzle to the bottom of the canvas. The last rows of the canvas still had alpha 11–19/255, so it ended in a straight horizontal line under the deck. It is now a world-space halo bounded by the ground, and the last 10 rows and columns read exactly 0.
- A wide `exp()` flash and the floor-coordinate steam were still visible at the canvas edges. Both now fall to zero inside the canvas.
- Cell noise looks only inside its own cell, so a feature whose offset plus radius plus soft tail reaches past the cell's half-width is clipped flat at the cell edge. Keep `offset + wobble + radius + tail` under half a cell, or test the 3 × 3 neighbouring cells. A blind-test bubble field let a centre sit 0.31 of a cell off-centre with a reach of 0.86 against a 0.85 half-cell.
- Radial noise that used `atan` had a seam along one angle. Use `noise3(vec3(dir, r·k − t))` of the direction vector instead.
- A CSS `radial-gradient` with percentage stops ended outside its element. Use `circle closest-side` so the gradient ends inside it.

### World space, in the camera
- The ignition shock was a 2D circle around the projected exit. Its lower arc ran over and under the deck and painted over both columns. It is now a real sphere: each pixel ray hits it at two roots, roots below the ground are hidden softly, each root is limb-weighted, and each one goes to the back or front canvas by the engine proxy.
- The floor splash was laid flat over the deluge ring, so the tube lost its volume. The ring is now a torus occluder, lit with `max(dot(n, toPlume), 0.)`.
- Haze behind the near column painted over its front face. Columns are now opaque boxes in the shader.

### Layering and blending
- In the dark theme, canvases that only emit light use `mix-blend-mode: screen` and write premultiplied colour with alpha = the largest channel. A canvas that also darkens what it covers stays normal-blended: the ripple tank's water tints the paper seen through it, which screen blending would silently drop. In the light theme every canvas is normal-blended, and its ink is solved from the colour the paper should show (section 8), not darkened from the dark-theme colour.
- A blurred orange rim drawn in normal blend over the white-hot core read as a dim brown collar under the nozzle lip, and drawn slightly off the lip it made a double arc. Glow outlines over a shader now go in their own overlay SVG (`.rp-glow`), screen-blended in dark, at the exact lip. In light nothing can add light, so a rim is part of the ink itself: a saturated band at the body's edge around a pale core (section 8.1).
- Steam that should pass in front of the bell was always behind it. It is now split across two canvases with the engine proxy (section 2).

### Tonemap and dither
- Use `tonemap()`, never `clamp`, so bright cores roll off instead of flattening.
- Without output dither, the large soft terms became 8-bit staircases (runs of 18–34 px per level), and screen blending made them worse. Dither after tonemap with about ±1 LSB of animated triangular noise, gated so empty pixels stay exactly 0 (the Raptor's `inkOf`):
  ```glsl
  col=tonemap(col,1.15);
  float m=max(col.r,max(col.g,col.b));
  float n=(hash12(gl_FragCoord.xy+fract(uTime*.61)*91.)+hash12(gl_FragCoord.yx*1.37+fract(uTime*.37)*57.)-1.)/255.;
  col=max(col+n*smoothstep(0.,1.5/255.,m),0.);
  ```
- Band-limit fine detail. The ripple tank fades out each wave whose projected wavelength drops below 2.5–5 device pixels (its `fine` term). Otherwise a 28 Hz pattern turns into moiré.

### Render only while it matters
- Cap the device ratio at 1.5 (the `maxRatio` default). Draw only while the effect is active: the Raptor clears both canvases and lets the loop stop once every envelope is below 1e-3. Stop offscreen with the same `IntersectionObserver` as the rest of the figure, and redraw on `resize`.
- An effect that never rests (bubbles in a returning flow, a pilot flame) keeps the shader drawing at idle, but not the rest of the figure: split the loop so the SVG work (depth sort, gauges, readout) runs only while something in it changes, and run the effect alone, at a reduced rate if it is slow. A blind-test bench kept its whole tick at 60 fps forever because one envelope settled at 0.54 instead of 0.
- Gate costly terms with uniform checks (`if(uSmoke<.0004&&uVent<.0004)return vec3(0.);`). These are the same for the whole draw, so they cost nothing when off.

### Reduced motion
Jump straight to a representative steady state (the Raptor jumps to main stage, and the ripple tank to its current f and d), freeze `uTime` (the Raptor uses 0.8), zero every event envelope (no flash, shock or shake), and redraw only when the JSON of the uniforms changes. The figure must still respond to input.

### Fallback
`glLayer` returns `null`, so guard every call. Mark the stage (`data-gl`) when WebGL runs so CSS can swap between the SVG fallback and the shader. Test by stubbing WebGL (section 9).

## 8. Light theme

Light is the shipping theme. A page that is light gets shaders designed for paper, not dark-plate glows converted at the end. Seven figures on the isometrics site were built on a dark plate and then converted: a pool reactor, a foundry pour, an afterburner, a glory hole, a tokamak, a plasma cutter and a forge. Each conversion took three review rounds, and every rule in this section is one of their findings.

The Raptor's light plume (`inkOf` in `examples/raptor-engine/live.js`) shows what not to do. It multiplies every hue by a fixed dark red, `vec3(.62,.34,.3)`, and sets alpha to brightness, so the hottest gas comes out darkest and most opaque. Reviewers called it "red ink smoke". Don't copy it. The ripple tank's grey ink (`uPale`) is still a good model for light that isn't a glow: a tone curve `1 − uExpo.x/(1 + (e/uExpo.y)^uExpo.z)` takes the crests to near paper-white and the troughs to the kit's greys, and its water writes only the change from flat water.

### 8.1 Ink on paper
Over white, the canvas is normal-blended, premultiplied, and never `screen`, because screen over white is white. No pixel can be brighter than the paper, so light has to read through **saturation and local contrast**:
- **A glow is a saturated body, a pale near-white core and a coloured rim.** The pale core reads as hot only because the body around it is saturated and the ground around that is darker.
- **What the light falls on takes a tint** (multiply-like, with capped alpha). It doesn't get brighter.
- **The volume that holds the light is darker, for a physical reason:** soot, refractory, deep water or shadow (section 8.3). It is never a black box.

**Design the colour the paper should show, then solve for the ink.** Write a ramp from light energy to the colour the reader should see over white. Then invert normal compositing over white, which shows `rgb + (1 − a)`:
```glsl
vec4 inkFor(vec3 seen,float cover){
  vec3 absorbed=1.-seen;
  float a=max(max(absorbed.r,max(absorbed.g,absorbed.b)),cover);
  return vec4(vec3(a)-absorbed,a);
}
```
Over white this composites to exactly `seen`.
- **Reactor.** `inkOf` does this with its `paperOf(e)` ramp, where `e = 1 − exp(−2.3·max(lin))`. The ramp runs white → `TINT (.85,.93,1)` → `BODY (.2,.46,.94)` (by e .12–.5) → `RIM (.2,.68,1)` (.4–.65) → `ICE (.9,.98,1)` (.6–.9).
- **Afterburner.** Its `paperOf` mixes white → body → a pale core, and then inverts the same way.
- **`cover` is an alpha floor at the hot end:** `WASH·smoothstep(.6,.9,e)` with WASH .52 in the reactor, and `COVER·smoothstep(.45,.92,m)` with COVER .94 in the afterburner. Without it, a near-white core has alpha near 0, so the SVG lines behind it show straight through and it reads as a hole, not as light.

The other figures use the form `vec4(colour·a, a)`: `colour` comes from a ramp and `a` from a saturating curve of energy. The tokamak's `glowInk` is an example. Both forms work; `inkFor` also guarantees the colour the reader sees.

**One ink per kind of light, composited in the shader.** Keep separate accumulators, give each its own ink, and stack them back to front with `vec4 over(vec4 top,vec4 under){return top+under*(1.-top.a);}`:

| Term | Ink | Seen in |
|---|---|---|
| Emission, the hot thing itself | ramp body plus a pale core, alpha up to .96–.97 | plasma cutter `bodyInk`, forge `barInk`, tokamak `glowInk` |
| Light falling on a surface | hue pulled warm or cool, alpha capped at .5–.8 | foundry `castInk` and `tintInk`, tokamak `tintInk` (.5), plasma cutter `tintInk` (.75–.8) |
| Haze: steam, smoke, fume, breakdown gas | grey or lightly tinted, alpha `1 − exp(−density·k)` | plasma cutter `hazeInk`, tokamak `gasInk`, forge steam |
| Sparks and streaks | on top of everything | foundry and forge `sparks` |

The plasma cutter's `lightInkOf` is the clearest example: `over(arcCore, over(bodyInk(body), over(arc, over(fumeInk, over(steamInk, tintInk(tint))))))`. If you sum the energies first and ink the sum, everything gets one hue and the pale core is lost.

**Finish.**
- Clamp `rgb ≤ a` after every sum: the afterburner showed a 1-LSB premultiply overflow.
- Dither the final premultiplied value, rgb and alpha both, gated by `smoothstep(0.,1.5/255.,a)` (the glass furnace's `paperOut`, the foundry's `dithered`).
- Keep the canvas edges at exactly 0.
- Tonemap energy before it reaches a ramp (`tonemap`, or `1 − exp(−k·e)`), never `clamp`.

### 8.2 Ink grows with light
In dark, more light only adds. On paper, more light means more ink, and a careless formula can add ink while the light falls or take ink away while it rises. These were the faults reviewers found most often:
- **Fade the finished ink once.** Scaling a premultiplied ink by `u` is an exact linear crossfade from bare paper to the full picture, monotonic in every pixel: the reactor's `ink*=uFirst`, the glass furnace's `paperOut(overInk(gas, ink)*uLit)`. The glory hole first faded each term before inking it (`*uLit` in `holeOf`, the port bore and the ball). The pale core arrived before its saturated body, then the ground darkened, so first light went pale and then dark. Build every light ink from unfaded tones (its `litOf()` returns 1 in light) and fade only the result.
- **Fade a glow by energy and alpha together.** If you fade only alpha, a pale core stays pale and the thinning glow turns milky grey: the tokamak's contact band did. If you fade only energy, the glow runs back down its ramp. The tokamak now scales energy down to a floor and alpha the rest of the way: `hot+=glowInk(c*level*(.3+.7*hold))*hold*(1.-hot.a)`. The band stays pink-mauve as it goes.
- **Take the hue at steady brightness.** As energy falls, the tonemap reddens and darkens the hue, and with alpha near its cap the ink rises: the afterburner's cavity got 55% heavier as the flame went out. The fix:
  - Divide the burn by the local lit strength before taking the hue (`burn/lit`, `lit = la.x·glowOf(la.y)/LIT_GLOW`).
  - Blend the hues by each part's share of the energy.
  - Tie alpha to energy: `cap·(1 − exp(−1.6·e/cap))`.
  - At low cover, warm the hue toward `EMBER` and scale alpha by `inkOf(hue)/inkOf(ember)`, so the ink stays the same.
  
  With that, the window's ink fell monotonically from 1894 to 155 on the cut.
- **Hand-offs are plain premultiplied mixes.** The afterburner's jet has two copies, dry blue and lit warm, crossfaded with `mix(blue, warm, lift)`. A `max(alpha)` cover there doubled the dry jet's ink. Also gate each copy so it can't pick up the other's terms: the dry shock disc leaked into the warm copy as a cream patch.
- **Envelopes that multiply can rise during a fade.** If a first-light envelope is still rising while the effect's own edge falls, the fade-out brightens: the tokamak did this when Home was pressed in its first half second. Freeze first-light growth while the effect is falling.
- **The handover from the SVG fallback is exact,** or the scene dips while the shader comes in (section 8.5).

### 8.3 A ground for the light
A glow on white with nothing around it reads as a stain. Give it the ground it really has:
- **Cavities in the SVG take a darker token set.** A group class redefines the kit tokens for everything inside it:
  - the afterburner's `.ab-inside`: paper `#4a453f`, shades `#3c3833` to `#544e47`, lines `#5e5952` to `#9b958b`;
  - the tokamak's `.tk-cavity`: `#66646e` to `#8b8992`;
  - the foundry's sooted bore tones (`--fd-bore-*-sooted`, a `color-mix` of `--anatomy-shade-0` with `--fd-soot-ink: #2f2a25`);
  - the glory hole's `--gf-void` and `--gf-bore`;
  - the reactor's water-column gradient (`--rx-water-top`, `-mid`, `-deep`, from `#edf2f4` to `#c9d7e0`).
  
  In dark the same tokens are set to `inherit`. All four shade tones stay, so the inside still has form and reads as refractory, soot or deep water rather than a hole.
- **The ground takes the light's tint, live.** The same tokens `color-mix` toward the light's hue by a variable the loop writes: `--ab-flame` at 30% on faces and 55% on lines and back plates, `--tk-cold-mix` for the violet of breakdown. The SVG ground then warms with the flame even without WebGL.
- **The shader's ground equals the SVG's.** Where the shader paints an opaque cavity (the glory hole's `holeOf = overInk(emberInk(t,1.), vec4(SOOT,1.))`), read the token rather than retyping it. The foundry adds a probe path, sets its fill to each `var(--fd-…)`, reads `getComputedStyle(probe).fill` and passes the result as a `fixed` uniform (`paintOf` in its `mount.ts`).
- **Light around the source is a tint with a long tail.** The tokamak's vessel read grey at flat-top until a long-range wash (`skin*(.14*exp(-gap/(.22*U))+.17*exp(-gap/(.9*U)))`) tinted its walls magenta. Measured on the column: `#6e6c76` with the plasma off, `#786778` with the wash at .17, which reads as lit. At .1 it still read grey.
- **No bloom in empty air.** In light, a halo around a stream or sheet hanging in the air is a smudge on the paper (the foundry's stream and notch glows were). Drop halos in air in light. Keep the limb rim inside the body, and the tint on the surfaces the light reaches.
- **Light only lands where the source can see.** The reactor's surface light leaked onto a shield's cut face and coping until its seal test used the true top of the tank lip (`RIMZ`) rather than the deck. The plasma cutter lit the slat bay under the jet's lower end while the jet was visibly in the next bay; its `bayLight` now lights each bay by its share of the jet column, so the light crossfades from bay to bay as the jet moves.

### 8.4 Recipes by phenomenon
Hot bodies run dull red → orange → yellow → near-white, in that order and no other. Keep one stop table in the model, compile it to GLSL and use the same table for the fallback: the foundry's `INK_STOPS` (`inkRampGlsl`, and `glowInk` for the SVG), the forge's `STEEL_INK` (`steelInk`, `steelInkOf`).
- **Molten metal, forged steel, billets, kerfs.**
  - Index the table by brightness temperature, so a dim far wall and a bright pour read from the same table. The foundry's `brightOf` inverts Wien from the green channel.
  - The forge's stops: 560 °C `(.45,.09,.05)`, 680 `(.56,.07,.04)`, 780 `(.78,.10,.03)`, 950 `(.95,.38,.06)`, 1150 `(1,.78,.12)`, 1260 `(1,.93,.78)`.
  - Alpha opens at visible glow: `steelGlows` over 560–820 °C, or the foundry's `INK_FROM`/`INK_FULL` over 470–590 °C, crossfading from the metal's own ground tone.
  - The rim is cooler and darker (`steelInk(t − 140)`, the foundry's 150 K limb drop). A cream core appears only in the hottest centre (`steelPale` over 1050–1280 °C, at .8).
  - A dark skin sits under the glow and grows as the metal cools: the forge's `barInk` runs from oxide `(.28,.29,.32)` to skin `(.24,.22,.23)` with cover up to .86.
  - Reviewers' words for the foundry stream: "a cream core with an orange skin".
- **Glass gathers.**
  - Alpha is a luminance gate: `GLOW_SEEN` .008–.028, about 600–710 °C.
  - The colour is `pow(hue, mix(1.6,.8,warm))*mix(.62,1.,warm)`, which runs deep cherry → red → orange.
  - The pale core (`CORE_PALE (1,.88,.62)`) sits where the pixel's own emission is brightest (`cored`, luminance .14–.24), not at a fixed radius.
  - The cool skin (`SKIN_COOL (.3,.11,.07)`) only recedes.
  - The first version had a salmon stage. A red mixed toward white is salmon, and no temperature looks like that: go through orange and yellow before anything turns pale.
- **Sparks.**
  - A saturated head and a darker, cooler tail. The plasma cutter's `tailCooling` sets the tail at 0.62 of the head's temperature; the forge's tail ink is `(.42,.07,.02)`, alpha .75 capped at .8. Only the youngest third of a spark gets a pale core (`u < .35`).
  - Keep the streak at least about 1.25 device px wide (`max(.35+.6px, 1.25px)`), or diagonal streaks bead at 10–24×.
  - On white a spark is ink, so over a hotter emitter it must vanish. The foundry multiplies spark ink by `1 − cover·smoothstep(sparkT−60, sparkT+60, tb)`, against both the metal and the glowing bore.
  - A spark that passes behind a jet fades by depth: `cover*smoothstep(-.4,.4,tJet-tS)`.
- **Flame and exhaust.**
  - A saturated orange-yellow body with translucent edges, weighted toward the axis in light: `mix(1.,1.3-.6*smoothstep(0.,rf,r),uLight)`.
  - Cut the limb mantle (`mix(.8,.3,uLight)`). The limb shell that gives a dark plume its edge (section 4) draws an outline on paper and leaves the flame hollow.
  - Keep raggedness to the outer part, widen the hot axis, and put the pale core at the nozzle, fading out over .4–.9 of the length.
  - Check a cross-section. The afterburner's final flame reads `0 2 13 33 31 49 78 72 78 52 40 32 8` in ink from edge to edge.
  - The dry jet is a faint blue column. Shock diamonds are pale cream discs (`CREAM (1,.95,.86)`) with a warm rim and a soft edge (about .65 of the disc radius). Pushed stronger, a second diamond read as a knob or a washer, so it stays a faint lens.
- **Smoke and steam.**
  - Light grey, never with a dark glow ink laid over it. Edge `(.84,.87,.90)`, crown `(.97,.975,.98)`, and the underside `(.72,.76,.81)` by the cloud's own self-shadow; alpha `1 − exp(−density·k)`.
  - Near a hot source, warm the cloud toward pale amber (`mix(steelInk(T), CREAM, .5)`). The forge first laid glow ink over the cloud, and reviewers saw a salmon blob.
  - A quench puff is a `burst` that leaves the tub: it climbs from 4 to about 40 units above the water, slowing as it goes (`PUFF` in the forge's `shader.ts`), clears the rim within about 0.15 s, widens from 7 to 13, thins as it grows, and shares the column's height window, so it never cuts off.
- **Plasma (tokamak).**
  - Saturated magenta-violet: `inkBody(chroma)=pow(chroma,vec3(1.8))*vec3(.93,.86,.97)`.
  - Above m .55 the core goes pale, toward white with 8% of the chroma kept.
  - Alpha: `.97*smoothstep(.02,.42,m)*(1.-exp(-2.6*(m+2.*m*m)))`.
  - Breakdown gas is a violet haze, `(.52,.44,1)` to `(.74,.68,1)`, alpha at most .64. Seen through the cutaway, the vessel's interior uses the cavity token set.
- **Cherenkov (reactor).**
  - Vivid cyan-blue in a blue-tinted water column: the paper ramp in 8.1, over the SVG water gradient, with the hot end floored by `WASH` so the core covers the fuel lines behind it.
  - Compress the pulse above critical (`1.+.75*log(level)`), so a 1.5 GW pulse brightens the pool without flooding the card.
  - The glow on the hall wall uses a separate, weaker ink (`HALL_INK` .35).
- **Plasma arc (plasma cutter).**
  - A violet arc `vec3(.6,.34,1.)` with a pale core `vec3(.96,.95,1.)`.
  - Warm tint ink on the plate and water: `mix(vec3(1.,.5,.08),vec3(1.,.68,.3),…)`, alpha `.75*(1.-exp(-3.2*sqrt(m)))`.

### 8.5 The SVG and the fallback in light
- **Read the theme once.** Choose the theme at build time (`pageHtml({ theme })`, or `data-theme` on the React wrapper; on the isometrics site the figure's build returns `theme` and `LivePlate` wraps a dark plate in its own `.iso[data-theme="dark"]`). The live code reads it from the nearest `[data-theme]` (`root.closest("[data-theme]")?.getAttribute("data-theme") !== "dark"`) and passes `uLight` as `fixed`. Gate every light-only change on `uLight` (`mix(dark, light, uLight)`, or a branch on the uniform), so dark output stays as it was. Keep dark working if that is cheap.
- **No colour literals.** If a figure hard-codes dark colours in its CSS, convert them to tokens with dark overrides: `.x { --x: light }` then `.iso[data-theme="dark"] .x { --x: dark }`. Shader constants that must match a token come from a probe (8.3).
- **Nested themes.** `.iso:not([data-theme="dark"]) .x` also matches through the light page around a dark plate. That is how the reactor's dark fallback got multiplied into black. Put the dark rule after the light one, at equal specificity, or key on the plate's own theme: the forge uses `.iso-plate[data-plate="light"]`.
- **How the fallback blends.** Multiply its saturated tints, which darken and leave the lines under them visible. Normal-blend its pale cores, because multiply can't lighten: the reactor's no-WebGL core is a radial gradient from `#fff` at .85 to `#dcefff` at .3, blended normally.
  - Fallback colours come from the same ramp as the shader: the foundry's `glowLightCss`, and the glass furnace's `gatherInk`, which mirrors the shader's gather ink exactly.
  - Opacity on a pale token fades to grey, so give the fallback the saturated hue: the tokamak's fallback rings set `--anatomy-lit: #e0409c` and `--anatomy-hi: #f27ec0`.
- **Fallback shape changes crossfade.** The tokamak's no-WebGL plasma ring is 11 precomputed rings (`LIMITED_RINGS`). Neighbouring rings are crossfaded with linear weights that sum to 1, on the same eased radius the shader draws. With 6 rings a doubled outline showed mid-fade.
- **Nothing with gaps goes under a translucent ink.** Whatever the SVG draws shows through an ink that isn't fully opaque. The forge's heat band was a set of strokes with paper between them, and it printed grey streaks through the bar's 14% transparency. It is now a face filled with the heat gradient.
- **The handover from fallback to shader is exact.** At first light the canvas ink rises by `u` while the fallback under it goes. A linear `1 − u` on the fallback makes the middle of the fade paler than either end. For an exact linear crossfade, set the fallback's opacity to `(1 − u)/(1 − u·α)`, where α is the canvas ink's alpha where it matters: the glass furnace's `--gf-gather-fade`, with α the gather's centre stop. Its other fallbacks stay at full strength until the shader is nearly in: `clamp(0, calc(var(--gf-fallback, 1) * 12), 1)`.
- **Every body the shader draws has a fallback.** The foundry's tap stream had none, so it was missing without WebGL. It is now an SVG outline rebuilt each frame from the same history the shader reads.

### 8.6 Where the ink meets a line
The canvas lies over the back SVG's 0.6 px strokes. In light, a misregistered edge shows as a pale gap or a coloured line over the stroke, rather than as a dark seam.
- **Stop at the stroke's inner edge.** Ramp a face's ink to full under the stroke and end it at the stroke's inner edge, half the line weight in: 0.3 vb for the kit's 0.6 px lines. Measure the computed stroke width; the forge's reviewer guessed 1.1. Examples: the reactor's `cutFade=smoothstep(-SEAM-.5*px,-SEAM+.5*px,gap)` with `SEAM=.3`, and the forge's `strokeMeet`. Pad a tint by the same half stroke (the foundry's `ringLight`), so it never paints over the line and leaves no pale band inside it.
- **Don't clear the ink where nothing is stroked.** The forge's veil cleared at the crease two faces share, which the SVG doesn't stroke, and white seams showed along the bar. Draw such a crease in the ink instead: ×0.72, at least 0.8 canvas px wide (sharper came out dotted).
- **Supersample where neighbouring rays disagree.** The reactor traces four sub-pixel rays and takes 16 samples when their hit, depth, kind, normal or cut share differ (`apart`), so cut-face edges don't stair-step. At dpr 1 a canvas pixel is about as wide as the stroke, so one pixel of softness remains.
- **A body that enters from out of frame** may fade in from the canvas edge in screen space. The foundry's tap stream fades in over 11 vb, starting 10.5 device px below the canvas top, so the edge still reads 0 and the stream reads as coming from above.

### 8.7 What the reviewers saw, and the fix

| You see in light | Cause | Fix |
|---|---|---|
| A red-brown "ink smoke" plume | Hue × a dark multiplier, alpha set to brightness | A paper ramp: saturated body, pale core, alpha floor (8.1) |
| A flame drawn as an outline, hollow inside | The dark limb mantle | Weight the body toward the axis, cut the mantle (8.4) |
| First light goes pale, then dark | Terms faded one by one before inking | Fade the finished premultiplied ink once (8.2) |
| A glow fades out through milky grey | Alpha-only fade of a pale ink | Dim energy and alpha together (8.2) |
| A cavity gets darker as the fire goes out | Tonemap reddens the hue while alpha holds | Hue at steady brightness, alpha from energy, ember match (8.2) |
| A salmon stage as glass or steel cools | Red mixed toward white | A blackbody-ordered ramp, pale only past yellow, core where emission peaks (8.4) |
| Sparks look like dirt on the metal | Dark ink over a hotter emitter | Drown sparks over hotter bodies; saturated head, darker tail (8.4) |
| Spark streaks beaded at zoom | Tails too thin | At least about 1.25 px wide; thin the tail less (8.4) |
| A spark shows through the jet | No depth test against the jet | Fade it by depth behind the jet (8.4) |
| A halo stains the paper around a stream | Dark-theme bloom in empty air | No halos in air in light (8.3) |
| A salmon blob in the steam | Glow ink laid over the cloud | Tint the cloud toward pale amber (8.4) |
| The vessel reads grey with the plasma on | Tint falls off too fast | A long-range wash term (8.3) |
| A pale fringe or white seam along an edge | Ink stops short of the stroke, or clears an unstroked crease | End the ink at the stroke's inner edge; draw the crease in ink (8.6) |
| A cream patch in the jet | One copy picks up another copy's term | Gate each copy (8.2) |
| The dark plate's fallback is invisible | A `:not([data-theme="dark"])` rule matched through the light page | Order the dark rule last, or key on the plate (8.5) |
| A grey, washed-out fallback glow | Opacity on a pale token | Saturated tokens; multiply tints, normal pale cores (8.5) |
| Grey streaks through a hot face | SVG strokes with gaps under a translucent ink | Fill the face (8.5) |
| Light on a face the source can't see | A surface light without the true occluder | Use the same geometry the SVG draws (8.3) |

## 8b. Shaders on a turning group
In a 3D figure (`references/3d.md`) parts move as rigid groups, so a shader has to follow them.
- **Anchor to a part**: `controller.anchor(group, restPoint, restDir)` gives `{ world, screen, direction }` every frame. Pass them as uniforms. That is how an LED rides a flexing fingertip, a flame stays on a ruptured line's end and a spark source sits on a knuckle.
- **Anchor to a surface**: prepend `glslPose("Dial")` (or `GLSL_TURN` for one group) and pass `TURN.poseUniforms(controller, "dial", "uDial")`. Then `vec3 q = toDial(onFloor(vb, z))` is the surface point in the group's rest-world coordinates. Oil pooling on a turntable, a texture revealed by light and a scorch mark all turn with it.
- **World space stays world space.** Smoke, sparks after they leave the source and oil on the bench never touch a group transform. Emit at the anchor's world position, then integrate in world.
- **Occlusion by live parts.** A front canvas sits over a live layer. Mask its ink with the parts painted after the effect's own part: `TURN.cover(controller, [partName])` → `uCoverEdge`, `uCoverCount`, then `ink *= 1. - coverOf(vb, .7 * px)`. The masks are the exact convex silhouettes pushed out 0.3 vb, so ink stops at the occluder's stroke. Six polygons of ten edges each. Read `MAX_FRAGMENT_UNIFORM_VECTORS` and keep the SVG fallback below 128.
- **A back canvas between live layers** needs no masks: put the surface that receives light in its own live layer under the parts that cover it (the turning dial's face under the hand).
- Vector uniform arrays (`uniform vec3 uSpark[24]`) take a `Float32Array` through `draw`.
- **The SVG fallback on a moving part** is a `T.billboard({ part, at, normal, svg })`. Its markup is in viewBox px centred on the projected point, not in world units, so scale world sizes by `G.scaleOf(P)`: a glow sized for a lens of radius 1.1 is `<circle r="${(1.1 * G.scaleOf(P)).toFixed(2)}"/>`. Written as `r="1.1"`, it draws a pin-dot about a third of the lens's size. Check it with `--no-webgl` and a 4× zoom.

## 9. Verify

Use `scripts/drive.mjs` for anything that moves; `capture.mjs` also has WebGL now, so it is fine for still shots of a shader page. `references/verify.md` has the usage. On top of the normal checklist:

- **A contact sheet across every transition:** start, each phase change, steady state and stop. Real-time sheets on a heavy WebGL page are too sparse, because a headless capture takes 60–200 ms and blocks animation frames. Slow the page's clock first with `["slow", 0.05]` (20× slow motion for `requestAnimationFrame` and `performance.now`), take the sheet, then `["slow", 1]`. To catch something that starts on the first frame, such as a first-light fade-in, pass `--preload` a file containing `window.__driveSpeed=0.04` so the page runs slowly from its first frame. Wait for a phase with `["until", "document.querySelector('[data-readout]').textContent.includes('main stage')", 8000]` rather than a guessed `wait`. Hold any idle tour off before each sheet: its resume timer runs on the wall clock, not the slowed one, so it can take the targets back mid-sheet. A key press holds it for a few real seconds; for a long slowed sheet, stub the timer with `--preload` and wait for rest first (`verify.md`, "Slow mode doesn't slow timers").
- **Numbers, not just pictures.** `canvas.getContext("webgl")` returns the page's own context. In a `requestAnimationFrame` callback, read uniforms with `gl.getUniform(gl.getParameter(gl.CURRENT_PROGRAM), location)` and read the mean canvas RGB with `gl.readPixels`. Readback only works in the same task as the draw, because `preserveDrawingBuffer` is off: all-zero frames from a separate task are a test artefact. Log one row per frame across a whole run. Hue and mean luminance should each move monotonically through a phase change, apart from deliberate bursts.
- **Edges read 0.** Read back the last 10 rows and columns of the canvas during every phase. Anything above 0 is a cut that will show under screen blending.
- **In light, log ink, not brightness.** Per frame, log the ink a region lays on white, `1 − lum(rgb + 1 − a)`, with the hook in `verify.md` ("Light-theme shaders"). Do it for each region that matters (a cavity, the core, a fallback-only area) across first light, every phase change and the fade-out. Ink must follow the physical quantity: up as the light comes in, down as it goes, with no hump and no dip. Stated as numbers, the afterburner's cut window fell 1894 → 155, and its light-off rose 154 → 1959 and settled at 1914.
- **In light, probe colours along the physical ramp.** For something that heats or cools, read the centre and rim pixel at several temperatures. They must run dull red → orange → yellow → near-white, with the rim cooler than the centre and no salmon or grey stage. The glory hole's table runs from 255/179/99 centre and 252/114/25 rim at 930 °C, through 211/63/6 and 165/28/1 at about 620 °C, to clear glass by 465 °C. For a volume, read a cross-section of ink from edge to edge: it should peak on the axis.
- **In light, check the dark path too.** Every light change sits behind `uLight`, so the dark build's frames should match its baseline. Also render a dark plate inside a light page, with and without WebGL (the nested-theme trap, section 8.5).
- **2× and 4× crops** of every place the effect meets the SVG: rims, lips, deck edges, wall bands, the paper corners, occluders, and every opening the effect is seen through (does it stop at the rim on all four sides, with the recess wall showing below?). Look for seams, hard edges, banding, a dark collar, a double line or a sudden colour switch. In light, also shoot 8–16× crops wherever ink meets a stroke (cut faces, rims, mouths, creases), and look for a pale gap between ink and line, ink painted over a line, or a stroke the ink cleared. Compare the same crop with the front SVG hidden and with the canvas hidden (set `style.display="none"` with an `eval`).
- **Fallback.** Run `drive.mjs` with `--no-webgl` (every `getContext("webgl")` returns `null`) and check that `data-gl` is absent and the SVG alone still reads, in both themes. In light, the fallback should show the same inks as the shader and the same dark ground, and every body the shader draws should be there. Film first light on a sheet with `--preload` slowing the page from its first frame: the handover from fallback to shader must not dip (section 8.5).
- **Reduced motion** (`--reduced`): one still, representative frame.
- **Console clean.** A shader compile error arrives as `console.error` from `onError`.

## 10. Performance

- Cost is roughly pixels × samples. The Raptor runs 28 volume samples, each with four shock-cell terms, plus a 28-step ground search with 7 bisections, an 8-sample halo, a 10-sample splash curtain and 6 steam samples per canvas, at up to 1.5× device ratio. It runs in SwiftShader, but its frame time hasn't been measured on a real GPU. Measure in a real browser's Performance panel before shipping something heavier, and if it's too slow, cut samples first, then `maxRatio`.
- The ripple tank's paper shader evaluates the two-source field four times per pixel (once for the refraction shift and three times for the caustic's fixed-point inverse), each source costing six texture reads of its history instead of a 24-step uniform loop. It measured about 1 ms per draw on a laptop GPU; phone cost is unmeasured.
- Know which GPU you measured. Headless Chrome in `drive.mjs` and `capture.mjs` uses the real GPU when it can and SwiftShader otherwise; log `gl.getParameter(gl.RENDERER)` before trusting a frame time.
- Spend samples only where they matter. Bound the march (a cylinder or box), start at the ground hit, and gate whole terms on uniforms.
- Two canvases cost two full-screen passes. Clear the one that has nothing to show (the Raptor's veil is drawn only while steam, vapour or the shock is present).
- Don't animate `uTime` if nothing in the field needs it. The ripple tank passes `draw(values, 0)` and moves only the wave phase, so a reduced-motion frame is truly still.
- When idle, stop the loop. A shader page that stays at 60 fps with nothing happening is a bug.

## scripts

```

```

## scripts/capture.mjs

```js
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
  const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
  child.on("exit", (code) => process.exit(code ?? 1));
} else {
  await main();
}

function usage() {
  console.log(`Capture a figure with headless Chrome.

  node capture.mjs <file.html | file.svg | url> [options]

  --out <png>         output path (default: capture.png in the current directory)
  --width <px>        viewport width (default 1440; use 390 for a phone)
  --height <px>       viewport height (default 1000)
  --scale <n>         device pixel ratio (default 2)
  --wait <ms>         time to let the page settle before the first shot (default 2500)
  --select <css>      clip to this element (default: .iso-plate, else the whole page)
  --zoom <x,y,w,h>    clip a region inside the selected element, in CSS px, for close-up checks
  --zoom-scale <n>    pixel ratio for --zoom shots (default 4)
  --hover <fx,fy>     move the mouse to this fraction of the selected element first (e.g. 0.6,0.5)
  --key <keys>        press keys on the focused figure first, comma separated (e.g. Tab,ArrowRight,7,Escape);
                      Tab is skipped (the figure is focused for you), a comma is written Comma
  --times <ms,...>    extra shots at these delays, each measured from the first shot (not from the previous one),
                      saved as name-<ms>.png; a capture takes 60-200 ms, so delays closer than that run late
  --reduced           emulate prefers-reduced-motion: reduce
  --theme <dark|light>  emulate prefers-color-scheme

Prints the clip box, console errors, uncaught exceptions and whether the page scrolls sideways.`);
}

function parse(argv) {
  const options = { width: 1440, height: 1000, scale: 2, wait: 2500, zoomScale: 4, times: [] };
  const rest = [];
  for (let index = 0; index < argv.length; index++) {
    const arg = argv[index];
    const next = () => argv[++index];
    if (arg === "--out") options.out = next();
    else if (arg === "--width") options.width = Number(next());
    else if (arg === "--height") options.height = Number(next());
    else if (arg === "--scale") options.scale = Number(next());
    else if (arg === "--wait") options.wait = Number(next());
    else if (arg === "--select") options.select = next();
    else if (arg === "--zoom") options.zoom = next().split(",").map(Number);
    else if (arg === "--zoom-scale") options.zoomScale = Number(next());
    else if (arg === "--hover") options.hover = next().split(",").map(Number);
    else if (arg === "--key") options.keys = next().split(",");
    else if (arg === "--times") options.times = next().split(",").map(Number);
    else if (arg === "--reduced") options.reduced = true;
    else if (arg === "--theme") options.theme = next();
    else if (arg === "--help" || arg === "-h") options.help = true;
    else rest.push(arg);
  }
  options.target = rest[0];
  return options;
}

function chromePath() {
  const candidates = [
    process.env.CHROME_PATH,
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/Applications/Chromium.app/Contents/MacOS/Chromium",
    "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
    "/usr/bin/google-chrome",
    "/usr/bin/google-chrome-stable",
    "/usr/bin/chromium",
    "/usr/bin/chromium-browser",
    "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
  ].filter(Boolean);
  const playwright = join(homedir(), "Library/Caches/ms-playwright");
  if (existsSync(playwright)) {
    for (const dir of readdirSync(playwright).sort().reverse()) {
      for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium"]) {
        candidates.push(join(playwright, dir, sub));
      }
    }
  }
  return candidates.find((path) => existsSync(path));
}

async function main() {
  const options = parse(process.argv.slice(2));
  if (options.help || !options.target) {
    usage();
    process.exit(options.help ? 0 : 1);
  }
  const url = /^https?:|^file:/.test(options.target) ? options.target : pathToFileURL(resolve(options.target)).href;
  const out = resolve(options.out ?? "capture.png");
  const binary = chromePath();
  if (!binary) {
    console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
    process.exit(1);
  }
  const profile = mkdtempSync(join(tmpdir(), "iso-capture-"));
  const chrome = spawn(binary, [
    "--headless=new",
    "--remote-debugging-port=0",
    `--user-data-dir=${profile}`,
    "--no-first-run",
    "--no-default-browser-check",
    "--hide-scrollbars",
    "--allow-file-access-from-files",
    "--enable-unsafe-swiftshader",
    "--ignore-gpu-blocklist",
    "about:blank",
  ]);
  const port = await new Promise((done, fail) => {
    let text = "";
    const timer = setTimeout(() => fail(new Error("Chrome did not start")), 20000);
    chrome.stderr.on("data", (chunk) => {
      text += chunk;
      const match = text.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
      if (match) {
        clearTimeout(timer);
        done(Number(match[1]));
      }
    });
  });
  const finish = (code) => {
    chrome.kill("SIGKILL");
    try {
      rmSync(profile, { recursive: true, force: true });
    } catch {}
    process.exit(code);
  };
  try {
    const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
    const socket = new WebSocket(target.webSocketDebuggerUrl);
    let id = 0;
    const pending = new Map();
    const problems = [];
    socket.onmessage = (message) => {
      const data = JSON.parse(message.data);
      if (data.id && pending.has(data.id)) {
        pending.get(data.id)(data);
        pending.delete(data.id);
      } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) {
        problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 400)}`);
      } else if (data.method === "Runtime.exceptionThrown") {
        const detail = data.params.exceptionDetails;
        problems.push(`exception: ${detail.exception?.description ?? detail.text}`.slice(0, 600));
      }
    };
    await new Promise((done) => (socket.onopen = done));
    const send = (method, params = {}) =>
      new Promise((done) => {
        const call = ++id;
        pending.set(call, done);
        socket.send(JSON.stringify({ id: call, method, params }));
      });
    const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })).result?.result?.value;
    const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
    await send("Runtime.enable");
    await send("Page.enable");
    const features = [];
    if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
    if (options.theme) features.push({ name: "prefers-color-scheme", value: options.theme });
    if (features.length) await send("Emulation.setEmulatedMedia", { features });
    await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: options.scale, mobile: options.width < 600 });
    await send("Page.navigate", { url });
    await sleep(Math.min(options.wait, 1500));
    await evaluate(`(async()=>{const h=document.documentElement.scrollHeight;for(let y=0;y<h;y+=400){scrollTo(0,y);await new Promise(r=>setTimeout(r,60))}scrollTo(0,0)})()`);
    await sleep(Math.max(0, options.wait - 1500));
    const select = options.select ?? ".iso-plate";
    const boxOf = async () =>
      evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(select)})||document.querySelector("svg")||document.body;el.scrollIntoView({block:"center"});const r=el.getBoundingClientRect();return {x:r.left+scrollX,y:r.top+scrollY,w:r.width,h:r.height,vx:r.left,vy:r.top}})()`);
    let box = await boxOf();
    if (options.hover) {
      await sleep(300);
      box = await boxOf();
      const x = box.vx + box.w * options.hover[0];
      const y = box.vy + box.h * options.hover[1];
      await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2, y: box.vy + 2 });
      await sleep(60);
      for (let step = 1; step <= 12; step++) {
        await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2 + ((x - box.vx - 2) * step) / 12, y: box.vy + 2 + ((y - box.vy - 2) * step) / 12 });
        await sleep(30);
      }
      await sleep(1200);
    }
    if (options.keys) {
      await evaluate(`(()=>{const f=document.querySelector('[tabindex="0"]');if(f)f.focus()})()`);
      const named = { " ": "Space", "-": "Minus", "=": "Equal", ",": "Comma", ".": "Period", "/": "Slash", ";": "Semicolon", "'": "Quote", "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "`": "Backquote" };
      for (const entry of options.keys) {
        if (entry === "Tab") continue;
        const key = entry === "Comma" ? "," : entry === "Space" ? " " : entry;
        const code = named[key] ?? (/^[0-9]$/.test(key) ? `Digit${key}` : key.length === 1 ? `Key${key.toUpperCase()}` : key);
        await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text: key.length === 1 ? key : undefined, windowsVirtualKeyCode: key === " " ? 32 : 0 });
        await send("Input.dispatchKeyEvent", { type: "keyUp", key, code });
        await sleep(250);
      }
      await sleep(1200);
    }
    const shoot = async (path, clip, scale) => {
      const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { ...clip, scale } });
      writeFileSync(path, Buffer.from(shot.result.data, "base64"));
    };
    const clipOf = () => {
      if (!options.zoom) return { clip: { x: box.x - 6, y: box.y - 6, width: box.w + 12, height: box.h + 12 }, scale: 1 };
      const [zx, zy, zw, zh] = options.zoom;
      return { clip: { x: box.x + zx, y: box.y + zy, width: zw, height: zh }, scale: options.zoomScale / options.scale };
    };
    const first = clipOf();
    await shoot(out, first.clip, first.scale);
    const shotAt = Date.now();
    const readouts = [];
    for (const delay of [...options.times].sort((a, b) => a - b)) {
      await sleep(Math.max(0, shotAt + delay - Date.now()));
      const next = clipOf();
      await shoot(out.replace(/\.png$/, `-${delay}.png`), next.clip, next.scale);
      readouts.push(`+${delay}ms "${await evaluate(`(document.querySelector("[data-readout]")||{}).textContent||""`)}"`);
    }
    const sideways = await evaluate(`document.documentElement.scrollWidth > innerWidth + 1`);
    const readout = await evaluate(`(document.querySelector("[data-readout]")||{}).textContent||""`);
    console.log(`saved ${out}`);
    console.log(`clip ${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.w)}x${Math.round(box.h)} · readout "${readout}" · scrolls sideways: ${sideways ? "YES" : "no"}`);
    if (readouts.length) console.log(`readout over time: ${readouts.join(" · ")}`);
    console.log(problems.length ? problems.join("\n") : "console: clean");
    socket.close();
    finish(0);
  } catch (error) {
    console.error(error);
    finish(1);
  }
}
```

## scripts/drive.mjs

```js
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { LINE_ENDS } from "./line-ends.mjs";

if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
  const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
  child.on("exit", (code) => process.exit(code ?? 1));
} else {
  await main();
}

function usage() {
  console.log(`Drive a figure in headless Chrome: move, click, type, wait, screenshot, and build contact sheets.

  node drive.mjs <file.html | url> '<actions as JSON>' [--width 1440] [--height 1100] [--scale 1] [--reduced] [--theme dark|light] [--no-webgl] [--preload file.js]
  node drive.mjs <file.html | url> --actions steps.json

  --reduced     emulate prefers-reduced-motion: reduce
  --theme t     emulate prefers-color-scheme: dark or light (only pages that read the media query follow it)
  --no-webgl    make canvas.getContext("webgl"/"webgl2") return null, to check the SVG fallback
  --preload js  run a script before the page's own scripts (counters, stubs, hooks)
  Kit pages set their theme at build time (body data-theme), so drive the -light.html build for light.
  Put anything with eval strings in an --actions file: inline JSON quoting breaks easily.

Actions run in order. A missing or null selector means the first .iso-plate (the key action focuses [tabindex="0"]).
Fractions are 0..1 across the element.
  ["scroll", sel]                         scroll an element into the middle of the viewport
  ["wait", ms]                            wait
  ["move", sel, fx, fy]                   glide the mouse to a point of the element (fires pointermove)
  ["click", sel, fx?, fy?]                click a point of the element (centre by default)
  ["drag", sel, fx0, fy0, fx1, fy1, steps?]
                                          press at one point of the element, glide to another in steps (20) one frame apart,
                                          release: real pointer events (pointerId 1), so setPointerCapture works
  ["key", key, sel?, code?]               focus sel (default [tabindex="0"]) and press a key ("i", "Escape", "ArrowRight", " ", "7");
                                          pass code for keys that share a name ("Shift" + "ShiftRight", "Enter" + "NumpadEnter")
  ["slow", factor]                        run the page's requestAnimationFrame and performance.now clock at factor x real
                                          time (0.05 = 20x slow motion) so sheets catch 100 ms events; ["slow", 1] restores it
  ["until", js, timeoutMs?]               poll a JS expression until it is truthy (default 10 s), e.g. a readout reaching a phase
  ["eval", js]                            run JS in the page and print the result
  ["shot", out.png, sel?, scale?, [x,y,w,h]?]           screenshot an element, or a region of it in CSS px
  ["lines", out.png?, sel?, options?]     check every texture line's two ends: each must land on a stroke, a dot or an outline
                                          (within options.tolerance, 1 viewBox px), or hide under a fill painted later; a short
                                          straight mark (a tick) needs one rooted end; lines with data-free and fade ramps are
                                          exempt. Prints each floating end in card px; out.png marks them with rings. Any failure
                                          makes the run exit 1
  ["turn", value, group?]                 set a 3D figure's turn through window.__isoTurn (a number sets the first turn group,
                                          group names another), then wait two animation frames
  ["orbit", out.png, from, to, step, sel?, scale?, [x,y,w,h]?, columns?]
                                          a contact sheet across angles of a 3D figure: each frame is turned, waits two
                                          animation frames, is captured and labelled with its angle
  ["sheet", out.png, every, count, sel?, scale?, [x,y,w,h]?, columns?]
                                          take count frames every ms and tile them into one PNG, to catch pops and seams;
                                          each capture also takes 60-200 ms (more on heavy WebGL pages), so combine with ["slow", 0.05] for transitions

Prints the readout after each shot, then console errors and exceptions.`);
}

function chromePath() {
  const candidates = [
    process.env.CHROME_PATH,
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/Applications/Chromium.app/Contents/MacOS/Chromium",
    "/usr/bin/google-chrome",
    "/usr/bin/google-chrome-stable",
    "/usr/bin/chromium",
    "/usr/bin/chromium-browser",
    "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
  ].filter(Boolean);
  const playwright = join(homedir(), "Library/Caches/ms-playwright");
  if (existsSync(playwright)) {
    for (const dir of readdirSync(playwright).sort().reverse()) {
      for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-linux/chrome"]) {
        candidates.push(join(playwright, dir, sub));
      }
    }
  }
  return candidates.find((path) => existsSync(path));
}

async function main() {
  const argv = process.argv.slice(2);
  if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
    usage();
    process.exit(argv.length ? 0 : 1);
  }
  const options = { width: 1440, height: 1100, scale: 1, reduced: false, theme: null, actions: null, noWebgl: false, preload: null };
  const rest = [];
  for (let index = 0; index < argv.length; index++) {
    const arg = argv[index];
    if (arg === "--width") options.width = Number(argv[++index]);
    else if (arg === "--height") options.height = Number(argv[++index]);
    else if (arg === "--scale") options.scale = Number(argv[++index]);
    else if (arg === "--reduced") options.reduced = true;
    else if (arg === "--theme") options.theme = argv[++index];
    else if (arg === "--no-webgl") options.noWebgl = true;
    else if (arg === "--preload") options.preload = readFileSync(argv[++index], "utf8");
    else if (arg === "--actions") options.actions = JSON.parse(readFileSync(argv[++index], "utf8"));
    else rest.push(arg);
  }
  const [target, script] = rest;
  const actions = options.actions ?? JSON.parse(script ?? "[]");
  const url = /^https?:|^file:/.test(target) ? target : pathToFileURL(resolve(target)).href;
  const binary = chromePath();
  if (!binary) {
    console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
    process.exit(1);
  }
  const profile = mkdtempSync(join(tmpdir(), "iso-drive-"));
  const chrome = spawn(binary, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--allow-file-access-from-files", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", "about:blank"]);
  const port = await new Promise((done, fail) => {
    let text = "";
    const timer = setTimeout(() => fail(new Error("Chrome did not start")), 20000);
    chrome.stderr.on("data", (chunk) => {
      text += chunk;
      const match = text.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
      if (match) {
        clearTimeout(timer);
        done(Number(match[1]));
      }
    });
  });
  const finish = (code) => {
    chrome.kill("SIGKILL");
    try {
      rmSync(profile, { recursive: true, force: true });
    } catch {}
    process.exit(code);
  };
  try {
    const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
    const socket = new WebSocket(page.webSocketDebuggerUrl);
    let id = 0;
    const pending = new Map();
    const problems = [];
    socket.onmessage = (message) => {
      const data = JSON.parse(message.data);
      if (data.id && pending.has(data.id)) {
        pending.get(data.id)(data);
        pending.delete(data.id);
      } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) {
        problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 600)}`);
      } else if (data.method === "Runtime.exceptionThrown") {
        const detail = data.params.exceptionDetails;
        problems.push(`exception: ${detail.exception?.description ?? detail.text}`.slice(0, 600));
      }
    };
    await new Promise((done) => (socket.onopen = done));
    const send = (method, params = {}) =>
      new Promise((done) => {
        const call = ++id;
        pending.set(call, done);
        socket.send(JSON.stringify({ id: call, method, params }));
      });
    const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })).result?.result?.value;
    const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
    await send("Runtime.enable");
    await send("Page.enable");
    const features = [];
    if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
    if (options.theme) features.push({ name: "prefers-color-scheme", value: options.theme });
    if (features.length) await send("Emulation.setEmulatedMedia", { features });
    const SLOW = `(()=>{const raf=window.requestAnimationFrame.bind(window);let real=null,virtual=0;window.__driveSpeed=1;window.requestAnimationFrame=(callback)=>raf((now)=>{if(real===null){real=now;virtual=now}virtual+=(now-real)*window.__driveSpeed;real=now;callback(virtual)});const clock=performance.now.bind(performance);let base=null,shown=0;performance.now=()=>{const now=clock();if(base===null){base=now;shown=now}shown+=(now-base)*window.__driveSpeed;base=now;return shown}})();`;
    const NO_GL = `(()=>{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(kind,...rest){if(/webgl/.test(String(kind)))return null;return get.call(this,kind,...rest)}})();`;
    const early = [SLOW, options.noWebgl ? NO_GL : "", options.preload ?? ""].filter(Boolean).join("\n");
    await send("Page.addScriptToEvaluateOnNewDocument", { source: early });
    await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: options.scale, mobile: options.width < 600 });
    await send("Page.navigate", { url });
    await sleep(1200);
    const boxOf = (selector) => evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector ?? ".iso-plate")})||document.body;const r=el.getBoundingClientRect();return {x:r.left,y:r.top,w:r.width,h:r.height,sx:scrollX,sy:scrollY}})()`);
    const readout = () => evaluate(`(document.querySelector("[data-readout]")||{}).textContent||""`);
    const capture = async (selector, scale = 1, region) => {
      const box = await boxOf(selector);
      const x = box.x + box.sx;
      const y = box.y + box.sy;
      const clip = region ? { x: x + region[0], y: y + region[1], width: region[2], height: region[3], scale } : { x: x - 4, y: y - 4, width: box.w + 8, height: box.h + 8, scale };
      const shot = await send("Page.captureScreenshot", { format: "png", clip, captureBeyondViewport: true });
      return shot.result.data;
    };
    const save = (out, base64) => {
      mkdirSync(dirname(resolve(out)), { recursive: true });
      writeFileSync(out, Buffer.from(base64, "base64"));
    };
    let mouse = [4, 4];
    let failed = 0;
    for (const action of actions) {
      const [kind, ...args] = action;
      if (kind === "wait") await sleep(args[0]);
      else if (kind === "scroll") await evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(args[0] ?? ".iso-plate")});if(el)el.scrollIntoView({block:"center"})})()`);
      else if (kind === "move") {
        const box = await boxOf(args[0]);
        const to = [box.x + box.w * args[1], box.y + box.h * args[2]];
        for (let step = 1; step <= 10; step++) {
          await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: mouse[0] + ((to[0] - mouse[0]) * step) / 10, y: mouse[1] + ((to[1] - mouse[1]) * step) / 10 });
          await sleep(16);
        }
        mouse = to;
      } else if (kind === "click") {
        const box = await boxOf(args[0]);
        const to = [box.x + box.w * (args[1] ?? 0.5), box.y + box.h * (args[2] ?? 0.5)];
        await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to[0], y: to[1] });
        await send("Input.dispatchMouseEvent", { type: "mousePressed", x: to[0], y: to[1], button: "left", clickCount: 1 });
        await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to[0], y: to[1], button: "left", clickCount: 1 });
        mouse = to;
      } else if (kind === "drag") {
        const box = await boxOf(args[0]);
        const from = [box.x + box.w * args[1], box.y + box.h * args[2]];
        const to = [box.x + box.w * args[3], box.y + box.h * args[4]];
        const steps = Math.max(1, args[5] ?? 20);
        await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from[0], y: from[1] });
        await send("Input.dispatchMouseEvent", { type: "mousePressed", x: from[0], y: from[1], button: "left", buttons: 1, clickCount: 1 });
        for (let step = 1; step <= steps; step++) {
          await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from[0] + ((to[0] - from[0]) * step) / steps, y: from[1] + ((to[1] - from[1]) * step) / steps, button: "left", buttons: 1 });
          await sleep(16);
        }
        await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to[0], y: to[1], button: "left", buttons: 0, clickCount: 1 });
        mouse = to;
      } else if (kind === "key") {
        await evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(args[1] ?? '[tabindex="0"]')});if(el)el.focus()})()`);
        const key = args[0];
        const named = { " ": "Space", "-": "Minus", "=": "Equal", ",": "Comma", ".": "Period", "/": "Slash", ";": "Semicolon", "'": "Quote", "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "`": "Backquote" };
        const code = args[2] ?? (named[key] ?? (/^[0-9]$/.test(key) ? `Digit${key}` : key.length === 1 ? `Key${key.toUpperCase()}` : key));
        await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text: key.length === 1 ? key : undefined, windowsVirtualKeyCode: key === " " ? 32 : 0 });
        await send("Input.dispatchKeyEvent", { type: "keyUp", key, code });
      } else if (kind === "eval") console.log("eval:", JSON.stringify(await evaluate(args[0])));
      else if (kind === "slow") await evaluate(`window.__driveSpeed=${Number(args[0])}`);
      else if (kind === "until") {
        const limit = Date.now() + (args[1] ?? 10000);
        let ok = false;
        while (Date.now() < limit) {
          if (await evaluate(`Boolean(${args[0]})`)) {
            ok = true;
            break;
          }
          await sleep(25);
        }
        if (!ok) console.log(`until: timed out waiting for ${args[0]}`);
      }
      else if (kind === "shot") {
        const [out, selector, scale, region] = args;
        save(out, await capture(selector, scale ?? 1, region));
        console.log(`shot ${out} · readout "${await readout()}"`);
      } else if (kind === "sheet") {
        const [out, every, count, selector, scale, region, columns] = args;
        const frames = [];
        const texts = [];
        for (let index = 0; index < count; index++) {
          if (index) await sleep(every);
          frames.push(await capture(selector, scale ?? 0.5, region));
          texts.push(await readout());
        }
        const tiled = await evaluate(`(async()=>{const frames=${JSON.stringify(frames)};const cols=${columns ?? Math.min(count, 6)};const images=await Promise.all(frames.map(src=>new Promise(done=>{const image=new Image();image.onload=()=>done(image);image.src="data:image/png;base64,"+src})));const w=images[0].width,h=images[0].height,rows=Math.ceil(images.length/cols);const canvas=document.createElement("canvas");canvas.width=w*cols;canvas.height=h*rows;const ctx=canvas.getContext("2d");ctx.fillStyle="#808080";ctx.fillRect(0,0,canvas.width,canvas.height);images.forEach((image,index)=>{ctx.drawImage(image,(index%cols)*w,Math.floor(index/cols)*h);ctx.fillStyle="#ff00aa";ctx.font="bold 12px monospace";ctx.fillText(String(index),(index%cols)*w+6,Math.floor(index/cols)*h+16)});return canvas.toDataURL("image/png").split(",")[1]})()`);
        save(out, tiled);
        console.log(`sheet ${out} · ${count} frames every ${every} ms · readouts ${texts.map((text, index) => `${index}:"${text}"`).join(" ")}`);
      } else if (kind === "turn") {
        const [value, group] = args;
        const call = group ? `window.__isoTurn.set(${JSON.stringify({ [group]: value })})` : `window.__isoTurn.set(${typeof value === "object" ? JSON.stringify(value) : Number(value)})`;
        await evaluate(`(()=>{if(!window.__isoTurn)return false;${call};return new Promise((done)=>requestAnimationFrame(()=>requestAnimationFrame(()=>done(true))))})()`);
      } else if (kind === "orbit") {
        const [out, from, to, step, selector, scale, region, columns] = args;
        const frames = [];
        const labels = [];
        for (let at = from; step > 0 ? at <= to + 1e-9 : at >= to - 1e-9; at += step) {
          const angle = Math.round(at * 1000) / 1000;
          await evaluate(`(()=>{if(!window.__isoTurn)return false;window.__isoTurn.set(${angle});return new Promise((done)=>requestAnimationFrame(()=>requestAnimationFrame(()=>done(true))))})()`);
          frames.push(await capture(selector, scale ?? 0.5, region));
          labels.push(`${angle}°`);
        }
        const tiled = await evaluate(`(async()=>{const frames=${JSON.stringify(frames)};const labels=${JSON.stringify(labels)};const cols=${columns ?? 6};const images=await Promise.all(frames.map(src=>new Promise(done=>{const image=new Image();image.onload=()=>done(image);image.src="data:image/png;base64,"+src})));const w=images[0].width,h=images[0].height,rows=Math.ceil(images.length/cols);const canvas=document.createElement("canvas");canvas.width=w*cols;canvas.height=h*rows;const ctx=canvas.getContext("2d");ctx.fillStyle="#808080";ctx.fillRect(0,0,canvas.width,canvas.height);images.forEach((image,index)=>{ctx.drawImage(image,(index%cols)*w,Math.floor(index/cols)*h);ctx.fillStyle="#ff00aa";ctx.font="bold 12px monospace";ctx.fillText(labels[index],(index%cols)*w+6,Math.floor(index/cols)*h+16)});return canvas.toDataURL("image/png").split(",")[1]})()`);
        save(out, tiled);
        console.log(`orbit ${out} · ${frames.length} frames ${from}°…${to}° every ${step}°`);
      } else if (kind === "lines") {
        const [out, selector, extra] = args;
        const result = await evaluate(`(${LINE_ENDS})(${JSON.stringify({ select: selector ?? null, show: Boolean(out), ...(extra ?? {}) })})`);
        if (!result) {
          console.log("lines: the check did not run");
          failed++;
          continue;
        }
        console.log(`line ends: ${result.problems.length} of ${result.ends} ends on ${result.lines} lines float`);
        for (const problem of result.problems) console.log(`  ${problem.what}  ${problem.end}${problem.mark ? " (a mark: neither end rooted)" : ""}  ${problem.gap == null ? "nothing nearby" : `${problem.gap.toFixed(2)} px from anything`}   at card ${problem.at.join(",")}   length ${problem.length}   d ${problem.d}…${problem.opacity < 1 ? `   opacity ${problem.opacity}` : ""}`);
        if (out) {
          save(out, await capture(selector, 2));
          await evaluate(`(document.querySelector("[data-line-ends]")||{remove(){}}).remove()`);
          console.log(`shot ${out}`);
        }
        failed += result.problems.length;
      } else console.log(`unknown action ${kind}`);
    }
    console.log(problems.length ? problems.join("\n") : "console: clean");
    socket.close();
    finish(failed ? 1 : 0);
  } catch (error) {
    console.error(error);
    finish(1);
  }
}
```

## scripts/inline-kit.mjs

```js
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const KIT = join(dirname(fileURLToPath(import.meta.url)), "..", "kit");
const strip = (file) => readFileSync(join(KIT, file), "utf8").replace(/^export /gm, "");

export function kitScript() {
  return strip("iso-kit.mjs");
}

export function glScript() {
  return strip("gl.mjs");
}

export function turnScript() {
  return strip("turn.mjs");
}

export function fixedScript() {
  return strip("turn-fixed.mjs") + "\n" + strip("canvas-painter.mjs");
}

export function glSharedScript() {
  return strip("gl-shared.mjs");
}
```

## scripts/line-ends.mjs

```js
function lineEnds({ select = null, tolerance = 1, root: rootSlack = 2, mark = 7, least = 0.3, cover = 0.5, show = false } = {}) {
  const plate = (select && document.querySelector(select)) || document.querySelector(".iso-plate") || document.body;
  const box = plate.getBoundingClientRect();
  const style = document.createElement("style");
  style.textContent = `${select || ".iso-plate"} *{pointer-events:none!important}${select || ".iso-plate"} svg,${select || ".iso-plate"} svg *{pointer-events:visiblePainted!important}`;
  document.head.append(style);
  const skipClass = ["iso-edge", "iso-bevel", "iso-crease", "tb-edge"];
  const svgs = [...plate.querySelectorAll("svg")];
  const shapes = svgs.flatMap((svg) => [...svg.querySelectorAll("path, line, polyline, polygon, circle, ellipse, rect")]).filter((el) => !el.closest("defs, clipPath, mask, pattern, marker, symbol"));
  const opacityOf = (el) => {
    let alpha = 1;
    for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
      const cs = getComputedStyle(node);
      if (cs.display === "none") return 0;
      alpha *= Number(cs.opacity);
      if (node === plate) break;
    }
    return getComputedStyle(el).visibility === "hidden" ? 0 : alpha;
  };
  const numbers = (text) => (text.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []).map(Number);
  const subpathsOf = (d) => {
    const out = [];
    let current = null;
    let x = 0;
    let y = 0;
    let sx = 0;
    let sy = 0;
    let control = null;
    const push = (px, py) => {
      current.points.push([px, py]);
      x = px;
      y = py;
    };
    const start = (px, py) => {
      current = { points: [[px, py]], closed: false };
      out.push(current);
      x = sx = px;
      y = sy = py;
    };
    const cubic = (x1, y1, x2, y2, px, py) => {
      const x0 = x;
      const y0 = y;
      for (let i = 1; i <= 8; i++) {
        const t = i / 8;
        const u = 1 - t;
        push(u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * px, u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * py);
      }
      control = [x2, y2];
    };
    const quad = (x1, y1, px, py) => {
      const x0 = x;
      const y0 = y;
      for (let i = 1; i <= 6; i++) {
        const t = i / 6;
        const u = 1 - t;
        push(u * u * x0 + 2 * u * t * x1 + t * t * px, u * u * y0 + 2 * u * t * y1 + t * t * py);
      }
      control = [x1, y1];
    };
    for (const [, command, args] of d.matchAll(/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g)) {
      const v = numbers(args);
      const rel = command === command.toLowerCase();
      const C = command.toUpperCase();
      const ox = () => (rel ? x : 0);
      const oy = () => (rel ? y : 0);
      if (C === "Z") {
        if (current) {
          current.closed = true;
          push(sx, sy);
        }
        current = null;
        continue;
      }
      if (C === "M") {
        for (let i = 0; i + 1 < v.length; i += 2) {
          if (i === 0) start(v[0] + ox(), v[1] + oy());
          else push(v[i] + ox(), v[i + 1] + oy());
        }
        control = null;
        continue;
      }
      if (!current) start(x, y);
      if (C === "L") for (let i = 0; i + 1 < v.length; i += 2) push(v[i] + ox(), v[i + 1] + oy());
      else if (C === "H") for (const value of v) push(value + ox(), y);
      else if (C === "V") for (const value of v) push(x, value + oy());
      else if (C === "C") for (let i = 0; i + 5 < v.length; i += 6) cubic(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy(), v[i + 4] + ox(), v[i + 5] + oy());
      else if (C === "S")
        for (let i = 0; i + 3 < v.length; i += 4) {
          const reflected = control ? [2 * x - control[0], 2 * y - control[1]] : [x, y];
          cubic(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
        }
      else if (C === "Q") for (let i = 0; i + 3 < v.length; i += 4) quad(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
      else if (C === "T")
        for (let i = 0; i + 1 < v.length; i += 2) {
          const reflected = control ? [2 * x - control[0], 2 * y - control[1]] : [x, y];
          quad(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy());
        }
      else if (C === "A") for (let i = 0; i + 6 < v.length; i += 7) push(v[i + 5] + ox(), v[i + 6] + oy());
      if (C !== "C" && C !== "S" && C !== "Q" && C !== "T") control = null;
    }
    return out;
  };
  const geometryOf = (el) => {
    const tag = el.tagName.toLowerCase();
    const num = (name) => Number(el.getAttribute(name) || 0);
    if (tag === "path") return subpathsOf(el.getAttribute("d") || "");
    if (tag === "line") return [{ points: [[num("x1"), num("y1")], [num("x2"), num("y2")]], closed: false }];
    if (tag === "polyline" || tag === "polygon") {
      const v = numbers(el.getAttribute("points") || "");
      const points = [];
      for (let i = 0; i + 1 < v.length; i += 2) points.push([v[i], v[i + 1]]);
      if (tag === "polygon" && points.length) points.push(points[0]);
      return [{ points, closed: tag === "polygon" }];
    }
    if (tag === "rect") {
      const [x, y, w, h] = [num("x"), num("y"), num("width"), num("height")];
      return [{ points: [[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]], closed: true }];
    }
    return [];
  };
  const cell = 6;
  const grid = new Map();
  const add = (gx, gy, entry) => {
    const id = `${gx},${gy}`;
    if (!grid.has(id)) grid.set(id, []);
    grid.get(id).push(entry);
  };
  const lines = [];
  let scale = 1;
  for (const el of shapes) {
    const cs = getComputedStyle(el);
    const alpha = opacityOf(el);
    if (alpha < 0.1) continue;
    const ctm = el.getScreenCTM();
    if (!ctm) continue;
    const map = ([px, py]) => [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
    const tag = el.tagName.toLowerCase();
    const stroked = cs.stroke !== "none" && parseFloat(cs.strokeWidth) > 0 && Number(cs.strokeOpacity) > 0.05;
    const filled = cs.fill !== "none" && Number(cs.fillOpacity) > 0.05;
    if (tag === "circle" || tag === "ellipse") {
      if (!stroked && !filled) continue;
      const c = map([Number(el.getAttribute("cx") || 0), Number(el.getAttribute("cy") || 0)]);
      const r = Number(el.getAttribute("r") || el.getAttribute("rx") || 0) * Math.hypot(ctm.a, ctm.b);
      const entry = { el, kind: "dot", c, r };
      for (let gx = Math.floor((c[0] - r) / cell); gx <= Math.floor((c[0] + r) / cell); gx++) for (let gy = Math.floor((c[1] - r) / cell); gy <= Math.floor((c[1] + r) / cell); gy++) add(gx, gy, entry);
      continue;
    }
    if (!stroked) continue;
    const owner = el.closest("svg");
    const viewCtm = owner && owner.getScreenCTM();
    if (viewCtm) scale = Math.hypot(viewCtm.a, viewCtm.b) || scale;
    const subpaths = geometryOf(el).map((sub, index) => ({ ...sub, index, flat: sub.points.map(map) }));
    subpaths.forEach((sub) => {
      for (let i = 0; i < sub.flat.length - 1; i++) {
        const a = sub.flat[i];
        const b = sub.flat[i + 1];
        const entry = { el, kind: "segment", a, b, sub: sub.index, i };
        for (let gx = Math.floor(Math.min(a[0], b[0]) / cell); gx <= Math.floor(Math.max(a[0], b[0]) / cell); gx++) for (let gy = Math.floor(Math.min(a[1], b[1]) / cell); gy <= Math.floor(Math.max(a[1], b[1]) / cell); gy++) add(gx, gy, entry);
      }
    });
    const checked = el.classList.contains("iso-line") && !skipClass.some((name) => el.classList.contains(name)) && alpha >= least;
    if (checked) lines.push({ el, subpaths, alpha, free: el.getAttribute("data-free") });
  }
  const near = (p, reach) => {
    const out = new Set();
    for (let gx = Math.floor((p[0] - reach) / cell); gx <= Math.floor((p[0] + reach) / cell); gx++) for (let gy = Math.floor((p[1] - reach) / cell); gy <= Math.floor((p[1] + reach) / cell); gy++) for (const entry of grid.get(`${gx},${gy}`) || []) out.add(entry);
    return out;
  };
  const segmentDistance = (p, a, b) => {
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const span = dx * dx + dy * dy;
    const t = span > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / span)) : 0;
    return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
  };
  const lengthOf = (flat) => flat.reduce((sum, p, i) => (i ? sum + Math.hypot(p[0] - flat[i - 1][0], p[1] - flat[i - 1][1]) : 0), 0);
  const hidden = (el, p) => {
    const stack = document.elementsFromPoint(p[0], p[1]);
    const at = stack.indexOf(el);
    const above = at < 0 ? stack : stack.slice(0, at);
    return above.some((node) => {
      if (!(node instanceof SVGGeometryElement) || node.contains(el)) return false;
      const cs = getComputedStyle(node);
      return cs.fill !== "none" && Number(cs.fillOpacity) * opacityOf(node) >= cover && node.isPointInFill(new DOMPoint(...inverse(node, p)));
    });
  };
  const inverse = (node, p) => {
    const m = node.getScreenCTM().inverse();
    return [m.a * p[0] + m.c * p[1] + m.e, m.b * p[0] + m.d * p[1] + m.f];
  };
  const clearance = (line, sub, end, p, reach) => {
    let best = Infinity;
    const own = sub.flat;
    const skipNear = 2 * reach;
    const along = [0];
    for (let i = 1; i < own.length; i++) along.push(along[i - 1] + Math.hypot(own[i][0] - own[i - 1][0], own[i][1] - own[i - 1][1]));
    const total = along[along.length - 1];
    for (const entry of near(p, reach * 4)) {
      if (entry.kind === "dot") {
        best = Math.min(best, Math.max(0, Math.hypot(p[0] - entry.c[0], p[1] - entry.c[1]) - entry.r));
        continue;
      }
      if (entry.el === line.el && entry.sub === sub.index) {
        const from = along[entry.i];
        const to = along[entry.i + 1];
        const gapFrom = end === "start" ? from : total - to;
        if (gapFrom < skipNear) continue;
      }
      best = Math.min(best, segmentDistance(p, entry.a, entry.b));
    }
    return best;
  };
  const describe = (el) => {
    const item = el.closest("[data-part], [data-name], [data-item], .it");
    const tag = item && item !== el ? (item.getAttribute("data-part") || item.getAttribute("data-name") || item.getAttribute("data-item") || `it k ${item.getAttribute("data-k") || ""}`) : "";
    return `${el.getAttribute("class")}${el.getAttribute("data-tone") ? ` ${el.getAttribute("data-tone")}` : ""}${tag ? ` in ${tag}` : ""}`;
  };
  const problems = [];
  let ends = 0;
  const slack = tolerance * scale;
  const rooted = rootSlack * scale;
  for (const line of lines) {
    for (const sub of line.subpaths) {
      if (sub.closed || sub.flat.length < 2) continue;
      const first = sub.flat[0];
      const last = sub.flat[sub.flat.length - 1];
      if (Math.hypot(first[0] - last[0], first[1] - last[1]) < 0.05 * scale) continue;
      const length = lengthOf(sub.flat);
      if (length < 0.2 * scale) continue;
      const result = {};
      for (const [end, p] of [["start", first], ["end", last]]) {
        if (line.free === "" || line.free === "both" || line.free === end) {
          result[end] = 0;
          continue;
        }
        ends++;
        result[end] = hidden(line.el, p) ? 0 : clearance(line, sub, end, p, Math.max(slack, rooted));
      }
      const straight = sub.flat.length === 2 || sub.flat.every((q) => segmentDistance(q, first, last) < 0.15 * scale);
      const isMark = straight && length <= mark * scale;
      const fail = (end) => result[end] !== undefined && result[end] > slack;
      let bad = [];
      if (isMark) {
        const best = Math.min(result.start ?? Infinity, result.end ?? Infinity);
        if (!(best <= rooted)) bad = ["start", "end"].filter((end) => result[end] !== undefined && result[end] > 0);
      } else bad = ["start", "end"].filter(fail);
      for (const end of bad) {
        const p = end === "start" ? first : last;
        problems.push({ what: describe(line.el), d: (line.el.getAttribute("d") || "").slice(0, 36), end, mark: isMark, gap: result[end] / scale, at: [Math.round((p[0] - box.left) * 10) / 10, Math.round((p[1] - box.top) * 10) / 10], client: p, opacity: Math.round(line.alpha * 100) / 100, length: Math.round((length / scale) * 10) / 10 });
      }
    }
  }
  style.remove();
  problems.sort((a, b) => b.gap - a.gap);
  if (show) {
    const ns = "http://www.w3.org/2000/svg";
    const overlay = document.createElementNS(ns, "svg");
    overlay.setAttribute("data-line-ends", "");
    overlay.setAttribute("style", `position:fixed;left:0;top:0;width:${innerWidth}px;height:${innerHeight}px;pointer-events:none;z-index:2147483647;overflow:visible`);
    for (const problem of problems) {
      const ring = document.createElementNS(ns, "circle");
      ring.setAttribute("cx", problem.client[0]);
      ring.setAttribute("cy", problem.client[1]);
      ring.setAttribute("r", 5);
      ring.setAttribute("style", "fill:none;stroke:#ff0050;stroke-width:1.5");
      overlay.append(ring);
    }
    document.body.append(overlay);
  }
  return { ends, lines: lines.length, problems: problems.map(({ client, ...rest }) => rest) };
}

export const LINE_ENDS = lineEnds.toString();
```

## scripts/lines.mjs

```js
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const argv = process.argv.slice(2);
if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
  console.log(`Check that every texture line in a figure ends on something.

  node lines.mjs <file.html | url> [--out marks.png] [--tolerance 1] [--select css] [--wait 1500] [drive.mjs flags]

Every open subpath of every .iso-line (seams, grooves, ribs, rule lines, ticks, needles) must end on another stroke,
a dot or an outline within the tolerance (viewBox px), or under a fill painted after it. A short straight mark (a tick)
needs one rooted end. Outlines, creases, bevels and tube edges are the kit's own and are not checked. Lines drawn with
fadeLineSvg ramp out and pass; lineSvg(d, { free: true | "start" | "end" }) marks an end that is meant to stop (a needle
tip, rule lines standing for printed text). Exits 1 if any end floats; --out writes the card with each one ringed.`);
  process.exit(argv.length ? 0 : 1);
}
const take = (flag, fallback) => {
  const at = argv.indexOf(flag);
  if (at < 0) return fallback;
  const value = argv[at + 1];
  argv.splice(at, 2);
  return value;
};
const out = take("--out", null);
const tolerance = Number(take("--tolerance", 1));
const select = take("--select", null);
const wait = Number(take("--wait", 1500));
const actions = JSON.stringify([["wait", wait], ["lines", out, select, { tolerance }]]);
const child = spawn(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "drive.mjs"), argv[0], actions, ...argv.slice(1)], { stdio: "inherit" });
child.on("exit", (code) => process.exit(code ?? 1));
```

## scripts/turn-bench.mjs

```js
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { TURN } from "../kit/turn.mjs";

const argv = process.argv.slice(2);
if (!argv.length || argv.includes("--help")) {
  console.log(`Time a 3D figure's runtime in Node: emit and order over 360 angles, CPU time, median of 7 rounds.

  node scripts/turn-bench.mjs examples/turning-dial/build.mjs [--rounds 7] [--unit 1]

The build module must export T (from turning()); if it also exports follow(values), every angle is mapped through it.`);
  process.exit(argv.length ? 0 : 1);
}
const take = (flag, fallback) => {
  const at = argv.indexOf(flag);
  return at < 0 ? fallback : Number(argv[at + 1]);
};
const rounds = take("--rounds", 7);
const unit = take("--unit", 1);
const mod = await import(pathToFileURL(resolve(argv[0])).href);
const T = mod.T;
const follow = mod.follow ?? ((values) => values);
const scene = TURN.prepare(T.data, { unit });
const runs = scene.runs;
const frames = runs.map((run) => TURN.frame(run));
const turn = T.groups.find((g) => g.kind === "turn");
const angles = Array.from({ length: 360 }, (_, i) => i + 0.37);
const cams = angles.map((angle) => TURN.cams(scene, follow({ [turn.name]: angle })));
for (let i = 0; i < 20; i++) for (const run of runs) TURN.emit(run, run.group >= 0 ? cams[i][run.group] : cams[i].world, frames[run.index]);
const perPart = runs.map(() => []);
const orderTimes = [];
const frameTimes = [];
const cpuFrames = [];
for (let round = 0; round < rounds; round++) {
  const sums = new Float64Array(runs.length);
  let orderSum = 0;
  const cpu0 = process.cpuUsage();
  const wall0 = performance.now();
  const previous = scene.layers.map((layer) => layer.rest);
  for (let a = 0; a < angles.length; a++) {
    const c = cams[a];
    for (const run of runs) {
      const t0 = performance.now();
      TURN.emit(run, run.group >= 0 ? c[run.group] : c.world, frames[run.index]);
      sums[run.index] += performance.now() - t0;
    }
    const t1 = performance.now();
    scene.layers.forEach((layer, index) => {
      previous[index] = TURN.order(layer, frames, c, previous[index], {}, scene);
    });
    orderSum += performance.now() - t1;
  }
  const cpu = process.cpuUsage(cpu0);
  cpuFrames.push((cpu.user + cpu.system) / 1000 / angles.length);
  frameTimes.push((performance.now() - wall0) / angles.length);
  orderTimes.push(orderSum / angles.length);
  runs.forEach((run, index) => perPart[index].push(sums[index] / angles.length));
}
const median = (list) => list.slice().sort((x, y) => x - y)[Math.floor(list.length / 2)];
const kinds = new Map();
runs.forEach((run, index) => {
  const kind = run.kind === "tube" ? "tube chunk" : run.kind;
  if (!kinds.has(kind)) kinds.set(kind, []);
  kinds.get(kind).push(median(perPart[index]));
});
console.log(`turn-bench ${argv[0]} · ${runs.length} parts · ${angles.length} angles · median of ${rounds} rounds · unit ${unit}`);
for (const [kind, list] of [...kinds].sort((a, b) => b[1].reduce((s, v) => s + v, 0) - a[1].reduce((s, v) => s + v, 0))) {
  const total = list.reduce((s, v) => s + v, 0);
  console.log(`  ${kind.padEnd(11)} ${String(list.length).padStart(4)} parts · ${(total / list.length).toFixed(4)} ms per part · ${total.toFixed(3)} ms per frame · slowest ${Math.max(...list).toFixed(4)} ms`);
}
const slow = runs.map((run, index) => [run.name, median(perPart[index])]).sort((a, b) => b[1] - a[1]).slice(0, 6);
console.log(`  slowest parts: ${slow.map(([name, ms]) => `${name} ${ms.toFixed(3)}`).join(" · ")}`);
console.log(`  order ${median(orderTimes).toFixed(3)} ms per frame (${scene.layers.map((layer) => `${layer.name}: ${layer.parts.length} parts, ${layer.planar.length / 5} planes, ${layer.dynamic.length / 2} dynamic`).join("; ")})`);
console.log(`  frame ${median(frameTimes).toFixed(3)} ms wall · ${median(cpuFrames).toFixed(3)} ms CPU`);
```

## scripts/turn-check.mjs

```js
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { LINE_ENDS } from "./line-ends.mjs";

function usage() {
  console.log(`Checks for 3D figures (turning groups).

  node turn-check.mjs build.mjs --fidelity [--angles 7,37,…]
      Node: every live prism against k.extrude / G.prismOf, every round and lathe against G.lathe(smooth) and every tube
      against tubePieces, at P'(θ). Control: one prism drawn 2° off must fail.
  node turn-check.mjs page.html [--order] [--pops] [--lines] [--perf] [--step 1] [--states states.json]
                                [--width 1440] [--height 1100] [--scale 2] [--throttle 1] [--no-webgl] [--out dir]
      --order   needs a --verify build: the recorded shapes in a WebGL z-buffer (part-ID colours, 2x) against the live
                layers' fills in DOM order. Fails on any live pair with 4 or more interior pixels. Static × live mismatches
                are listed (the layer proofs and the orbit audit own them). Control: each live layer's order reversed at
                0, 90, 180 and 270 must give at least 1000 mismatched pixels.
      --pops    first difference θ → θ+0.02° at every step, and second difference at 0.1° over a full turn; control: one
                part's fill hidden for one frame must fail. Run with --no-webgl and the tour held.
      --lines   LINE_ENDS at 24 angles (run it on the light and the dark page); control: a planted floating line must fail.
      --perf    240 frames at 1.1° per frame driven through the controller inside requestAnimationFrame: script, task,
                style and layout per frame, frame interval median and p95, live elements, writes and moves per frame.
      --states  a JSON list of value objects to visit instead of angles (gestures, a break-apart).
  The angle is the figure's first turn group (window.__isoTurn.set(θ)).`);
}

function chromePath() {
  const candidates = [process.env.CHROME_PATH, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean);
  const playwright = join(homedir(), "Library/Caches/ms-playwright");
  if (existsSync(playwright)) for (const dir of readdirSync(playwright).sort().reverse()) for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-linux/chrome"]) candidates.push(join(playwright, dir, sub));
  return candidates.find((path) => existsSync(path));
}

async function browser(target, { width, height, scale, throttle, noWebgl }) {
  const profile = mkdtempSync(join(tmpdir(), "turn-check-"));
  const chrome = spawn(chromePath(), ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--allow-file-access-from-files", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", "about:blank"]);
  const port = await new Promise((done, fail) => {
    let text = "";
    const timer = setTimeout(() => fail(new Error("Chrome did not start")), 20000);
    chrome.stderr.on("data", (chunk) => {
      text += chunk;
      const match = text.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
      if (match) {
        clearTimeout(timer);
        done(Number(match[1]));
      }
    });
  });
  const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
  const socket = new WebSocket(page.webSocketDebuggerUrl);
  let id = 0;
  const pending = new Map();
  const problems = [];
  socket.onmessage = (message) => {
    const data = JSON.parse(message.data);
    if (data.id && pending.has(data.id)) {
      pending.get(data.id)(data);
      pending.delete(data.id);
    } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 400)}`);
    else if (data.method === "Runtime.exceptionThrown") problems.push(`exception: ${data.params.exceptionDetails.exception?.description ?? data.params.exceptionDetails.text}`.slice(0, 400));
  };
  await new Promise((done) => (socket.onopen = done));
  const send = (method, params = {}) =>
    new Promise((done) => {
      const call = ++id;
      pending.set(call, done);
      socket.send(JSON.stringify({ id: call, method, params }));
    });
  const evaluate = async (expression) => {
    const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
    if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails).slice(0, 800));
    return result.result?.result?.value;
  };
  await send("Runtime.enable");
  await send("Page.enable");
  const NO_GL = `(()=>{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(kind,...rest){if(/webgl/.test(String(kind))&&!this.dataset.check)return null;return get.call(this,kind,...rest)}})();`;
  if (noWebgl) await send("Page.addScriptToEvaluateOnNewDocument", { source: NO_GL });
  await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: width < 600 });
  if (throttle > 1) await send("Emulation.setCPUThrottlingRate", { rate: throttle });
  const url = /^https?:|^file:/.test(target) ? target : pathToFileURL(resolve(target)).href;
  await send("Page.navigate", { url });
  await new Promise((done) => setTimeout(done, 1500));
  const close = () => {
    socket.close();
    chrome.kill("SIGKILL");
    try {
      rmSync(profile, { recursive: true, force: true });
    } catch {}
  };
  return { send, evaluate, close, problems };
}

const HELPERS = String.raw`(() => {
  const stage = document.querySelector(".iso-turn") || document.querySelector(".iso-stage");
  const svgs = [...stage.querySelectorAll(":scope > svg")];
  const hook = window.__isoTurn;
  const C = hook.controller;
  const vb = svgs[0].viewBox.baseVal;
  const W = vb.width;
  const H = vb.height;
  const css = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n");
  const theme = (stage.closest("[data-theme]") || document.body).getAttribute("data-theme") || "dark";
  const setState = (state) => (typeof state === "number" ? C.set(state) : C.set(state));
  const matrixOf = (el) => {
    const m = el.ownerSVGElement.getScreenCTM().inverse().multiply(el.getScreenCTM());
    return "matrix(" + m.a + " " + m.b + " " + m.c + " " + m.d + " " + m.e + " " + m.f + ")";
  };
  const decode = async (svg, w, h) => {
    const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
    const image = new Image();
    await new Promise((done, fail) => { image.onload = done; image.onerror = fail; image.src = url; });
    const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    ctx.drawImage(image, 0, 0, w, h);
    URL.revokeObjectURL(url);
    return ctx.getImageData(0, 0, w, h).data;
  };
  window.__tc = { stage, svgs, hook, C, W, H, css, theme, setState, matrixOf, decode };
  return { W, H, layers: svgs.map((s) => s.dataset.live || null), theme };
})()`;

const ZBUFFER = String.raw`(() => {
  const { svgs, C, W, H, setState, matrixOf, decode } = window.__tc;
  const V = window.__isoVerify;
  const SCALE = 2;
  const w = W * SCALE;
  const h = H * SCALE;
  const STATIC = 30000;
  const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
  const mul3 = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
  const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
  const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  const unit3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
  const frameOf = (F) => {
    if (F.u && F.v) return F;
    const a = unit3(F.a);
    const helper = Math.abs(a[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
    const u = unit3(cross3(helper, a));
    return { o: F.o, a, u, v: cross3(a, u) };
  };
  function meshOf(shape) {
    const tris = [];
    const quad = (a, b, c, d) => tris.push(a, b, c, a, c, d);
    if (shape.kind === "prism") {
      const F = frameOf(shape.F);
      const at = (s, [x, y]) => add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, x), mul3(F.v, y)));
      const n = shape.poly.length;
      const centre = shape.poly.reduce((s, p) => [s[0] + p[0] / n, s[1] + p[1] / n], [0, 0]);
      for (const s of [shape.s0, shape.s1]) for (let i = 0; i < n; i++) tris.push(at(s, centre), at(s, shape.poly[i]), at(s, shape.poly[(i + 1) % n]));
      for (let i = 0; i < n; i++) quad(at(shape.s0, shape.poly[i]), at(shape.s0, shape.poly[(i + 1) % n]), at(shape.s1, shape.poly[(i + 1) % n]), at(shape.s1, shape.poly[i]));
    } else if (shape.kind === "body") {
      const F = frameOf(shape.F);
      const count = 72;
      const ring = (s, r) => Array.from({ length: count }, (_, j) => { const t = (j / count) * Math.PI * 2; return add3(add3(F.o, mul3(F.a, s)), add3(mul3(F.u, r * Math.cos(t)), mul3(F.v, r * Math.sin(t)))); });
      const poly = shape.poly;
      for (let i = 0; i < poly.length; i++) {
        const [s0, r0] = poly[i];
        const [s1, r1] = poly[(i + 1) % poly.length];
        if (r0 < 1e-9 && r1 < 1e-9) continue;
        const a = ring(s0, r0);
        const b = ring(s1, r1);
        for (let j = 0; j < count; j++) quad(a[j], a[(j + 1) % count], b[(j + 1) % count], b[j]);
      }
    } else if (shape.kind === "ball") {
      const rows = 32;
      const cols = 64;
      const flats = shape.flats || [];
      const at = (i, j) => {
        const phi = (i / rows) * Math.PI;
        const t = (j / cols) * Math.PI * 2;
        let n = [Math.sin(phi) * Math.cos(t), Math.sin(phi) * Math.sin(t), Math.cos(phi)];
        let p = mul3(n, shape.r);
        for (const [nx, ny, nz, d] of flats) {
          const k = nx * p[0] + ny * p[1] + nz * p[2];
          if (k > d) p = sub3(p, mul3([nx, ny, nz], k - d));
        }
        return add3(shape.o, p);
      };
      for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) quad(at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j));
    } else if (shape.kind === "box") {
      const c = (sx, sy, sz) => add3(shape.o, add3(add3(mul3(shape.axes[0], sx * shape.half[0]), mul3(shape.axes[1], sy * shape.half[1])), mul3(shape.axes[2], sz * shape.half[2])));
      const v = [c(-1, -1, -1), c(1, -1, -1), c(1, 1, -1), c(-1, 1, -1), c(-1, -1, 1), c(1, -1, 1), c(1, 1, 1), c(-1, 1, 1)];
      for (const [a, b, cc, d] of [[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]) quad(v[a], v[b], v[cc], v[d]);
    } else if (shape.kind === "tube") {
      const pts = [];
      const radii = [];
      shape.points.forEach((p, i) => {
        if (pts.length && Math.hypot(p[0] - pts[pts.length - 1][0], p[1] - pts[pts.length - 1][1], p[2] - pts[pts.length - 1][2]) < 1e-6) return;
        pts.push(p);
        radii.push(shape.radii[i]);
      });
      if (pts.length < 2) return tris;
      const count = 20;
      let u = null;
      const rings = pts.map((p, i) => {
        const t = unit3(sub3(pts[Math.min(pts.length - 1, i + 1)], pts[Math.max(0, i - 1)]));
        if (!u) { const helper = Math.abs(t[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1]; u = unit3(cross3(helper, t)); } else u = unit3(sub3(u, mul3(t, dot3(u, t))));
        const v = cross3(t, u);
        const r = radii[i];
        return Array.from({ length: count }, (_, j) => { const a = (j / count) * Math.PI * 2; return add3(p, add3(mul3(u, r * Math.cos(a)), mul3(v, r * Math.sin(a)))); });
      });
      for (let i = 0; i < rings.length - 1; i++) for (let j = 0; j < count; j++) quad(rings[i][j], rings[i][(j + 1) % count], rings[i + 1][(j + 1) % count], rings[i + 1][j]);
      for (const [index, p] of [[0, pts[0]], [rings.length - 1, pts[pts.length - 1]]]) for (let j = 0; j < count; j++) tris.push(p, rings[index][j], rings[index][(j + 1) % count]);
    }
    return tris;
  }
  const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
  canvas.dataset.check = "1";
  const gl = canvas.getContext("webgl", { antialias: false, preserveDrawingBuffer: true, alpha: false, depth: true });
  const vs = "attribute vec3 p;uniform vec3 uR0;uniform vec3 uR1;uniform vec3 uR2;uniform vec3 uT;uniform vec3 uM0;uniform vec3 uM1;uniform vec2 uO;uniform vec3 uV;uniform vec2 uView;uniform float uSpan;void main(){vec3 q=vec3(dot(uR0,p),dot(uR1,p),dot(uR2,p))+uT;vec2 s=uO+vec2(dot(uM0,q),dot(uM1,q));gl_Position=vec4(s.x/uView.x*2.-1.,1.-s.y/uView.y*2.,-dot(uV,q)/uSpan,1.);}";
  const fs = "precision highp float;uniform vec3 uId;void main(){gl_FragColor=vec4(uId,1.);}";
  const compile = (type, source) => { const sh = gl.createShader(type); gl.shaderSource(sh, source); gl.compileShader(sh); if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh)); return sh; };
  const program = gl.createProgram();
  gl.attachShader(program, compile(gl.VERTEX_SHADER, vs));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(program);
  gl.useProgram(program);
  const loc = (name) => gl.getUniformLocation(program, name);
  const where = { p: gl.getAttribLocation(program, "p"), R0: loc("uR0"), R1: loc("uR1"), R2: loc("uR2"), T: loc("uT"), M0: loc("uM0"), M1: loc("uM1"), O: loc("uO"), V: loc("uV"), view: loc("uView"), span: loc("uSpan"), id: loc("uId") };
  const bufferOf = (shapes) => {
    const pos = [];
    for (const shape of shapes) for (const p of meshOf(shape)) pos.push(p[0], p[1], p[2]);
    const b = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, b);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pos), gl.STATIC_DRAW);
    return { b, count: pos.length / 3 };
  };
  const parts = V.parts.map((part, index) => ({ index, group: part.group, name: part.name, mesh: bufferOf(part.shapes) }));
  const statics = V.statics.map((item) => ({ rank: item.rank, mesh: bufferOf(item.shapes) }));
  const colour = (id) => [((id + 1) & 255) / 255, (((id + 1) >> 8) & 255) / 255, 0];
  const zIds = () => {
    const cams = C.cams();
    const world = cams.world;
    gl.viewport(0, 0, w, h);
    gl.enable(gl.DEPTH_TEST);
    gl.depthFunc(gl.LESS);
    gl.clearColor(0, 0, 0, 1);
    gl.clearDepth(1);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniform3f(where.M0, world.m[0], world.m[1], world.m[2]);
    gl.uniform3f(where.M1, world.m[3], world.m[4], world.m[5]);
    gl.uniform2f(where.O, world.ox, world.oy);
    gl.uniform3f(where.V, world.V[0], world.V[1], world.V[2]);
    gl.uniform2f(where.view, W, H);
    gl.uniform1f(where.span, 4000);
    const draw = (mesh, R, t, id) => {
      gl.uniform3f(where.R0, R[0], R[1], R[2]);
      gl.uniform3f(where.R1, R[3], R[4], R[5]);
      gl.uniform3f(where.R2, R[6], R[7], R[8]);
      gl.uniform3f(where.T, t[0], t[1], t[2]);
      gl.uniform3fv(where.id, colour(id));
      gl.bindBuffer(gl.ARRAY_BUFFER, mesh.b);
      gl.enableVertexAttribArray(where.p);
      gl.vertexAttribPointer(where.p, 3, gl.FLOAT, false, 0, 0);
      gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
    };
    const eye = [1, 0, 0, 0, 1, 0, 0, 0, 1];
    for (const item of statics) draw(item.mesh, eye, [0, 0, 0], STATIC + item.rank);
    for (const part of parts) {
      const cam = part.group >= 0 ? cams[part.group] : null;
      draw(part.mesh, cam ? cam.R : eye, cam ? cam.t : [0, 0, 0], part.index);
    }
    const pixels = new Uint8Array(w * h * 4);
    gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    const ids = new Int32Array(w * h);
    for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const i = ((h - 1 - y) * w + x) * 4; ids[y * w + x] = pixels[i] + pixels[i + 1] * 256 - 1; }
    return ids;
  };
  const toId = (id) => "rgb(" + ((id + 1) & 255) + "," + (((id + 1) >> 8) & 255) + ",0)";
  const painterSvg = (reverse = false) => {
    const out = [];
    let rank = 0;
    for (const svg of svgs) {
      if (svg.dataset.live) {
        const nodes = [...svg.querySelectorAll("[data-p]")];
        if (reverse) nodes.reverse();
        for (const node of nodes) for (const el of node.querySelectorAll(".iso-fill, .tb-body")) out.push('<path d="' + el.getAttribute("d") + '" transform="' + matrixOf(el) + '" fill-rule="' + (el.getAttribute("fill-rule") || "nonzero") + '" fill="' + toId(Number(node.dataset.p)) + '"/>');
      } else {
        for (const el of svg.querySelectorAll(".iso-fill, .tb-body")) out.push('<path d="' + el.getAttribute("d") + '" transform="' + matrixOf(el) + '" fill-rule="' + (el.getAttribute("fill-rule") || "nonzero") + '" fill="' + toId(STATIC + rank) + '"/>');
        rank++;
      }
    }
    return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + " " + H + '" width="' + w + '" height="' + h + '" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="rgb(0,0,0)"/>' + out.join("") + "</svg>";
  };
  const pIds = async (reverse) => {
    const data = await decode(painterSvg(reverse), w, h);
    const ids = new Int32Array(w * h);
    for (let i = 0; i < w * h; i++) ids[i] = data[i * 4] + data[i * 4 + 1] * 256 - 1;
    return ids;
  };
  const nameOf = (id) => (id >= STATIC ? "static layer " + (id - STATIC) : id < 0 ? "empty" : V.parts[id].name);
  const joint = (a, b) => {
    const m = /^(.*):(\d+)$/;
    const x = a.match(m);
    const y = b.match(m);
    return Boolean(x && y && x[1] === y[1] && Math.abs(Number(x[2]) - Number(y[2])) <= 1);
  };
  async function check(state, { reverse = false } = {}) {
    setState(state);
    const Z = zIds();
    const P = await pIds(reverse);
    const tally = new Map();
    const interior = (ids, i, x, y) => {
      const v = ids[i];
      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; const yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h || ids[yy * w + xx] !== v) return false; }
      return true;
    };
    let total = 0;
    for (let y = 1; y < h - 1; y++)
      for (let x = 1; x < w - 1; x++) {
        const i = y * w + x;
        const p = P[i];
        const z = Z[i];
        if (p === z || p < 0 || z < 0) continue;
        if (!interior(P, i, x, y) || !interior(Z, i, x, y)) continue;
        const key = p + "|" + z;
        tally.set(key, (tally.get(key) || 0) + 1);
        total++;
      }
    const live = [];
    const layers = [];
    for (const [key, px] of tally) {
      const [p, z] = key.split("|").map(Number);
      const a = nameOf(p);
      const b = nameOf(z);
      if (p >= STATIC || z >= STATIC) layers.push({ painter: a, truth: b, px });
      else if (!joint(a, b)) live.push({ painter: a, truth: b, px });
    }
    live.sort((x, y) => y.px - x.px);
    layers.sort((x, y) => y.px - x.px);
    return { total, live, layers, stats: C.stats() };
  }
  async function footprint(state, name) {
    setState(state);
    const index = V.parts.findIndex((part) => part.name === name);
    const Z = zIds();
    const P = await pIds(false);
    const boxOf = (ids) => {
      let n = 0, x0 = w, y0 = h, x1 = -1, y1 = -1;
      for (let i = 0; i < ids.length; i++) if (ids[i] === index) { n++; const x = i % w, y = (i / w) | 0; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
      return { px: n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE] : null };
    };
    return { painter: boxOf(P), truth: boxOf(Z) };
  }
  window.__tc.order = { check, footprint };
  return { parts: parts.length, statics: statics.length, w, h };
})()`;

const POPS = String.raw`(() => {
  const { stage, svgs, W, H, css, theme, setState, decode } = window.__tc;
  const SCALE = 2;
  const w = W * SCALE;
  const h = H * SCALE;
  let hidden = null;
  const svgString = () => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + " " + H + '" width="' + w + '" height="' + h + '" class="iso iso-svg" data-theme="' + theme + '" style="--anatomy-paper:var(--anatomy-card)"><style>' + css + '</style><rect width="100%" height="100%" style="fill:var(--anatomy-card)"/>' + svgs.map((s) => '<g class="' + (s.getAttribute("class") || "") + '">' + s.innerHTML + "</g>").join("") + "</svg>";
  async function render(state, hide = null) {
    setState(state);
    if (hide !== null) {
      hidden = stage.querySelector('[data-p="' + hide + '"]');
      if (hidden) hidden.setAttribute("display", "none");
    }
    const data = await decode(svgString(), w, h);
    if (hidden) {
      hidden.removeAttribute("display");
      hidden = null;
    }
    const lum = new Float32Array(w * h);
    for (let i = 0; i < w * h; i++) lum[i] = 0.2126 * data[i * 4] + 0.7152 * data[i * 4 + 1] + 0.0722 * data[i * 4 + 2];
    return lum;
  }
  const erode = (mask) => {
    const out = new Uint8Array(w * h);
    for (let y = 1; y < h - 1; y++)
      for (let x = 1; x < w - 1; x++) {
        const i = y * w + x;
        if (!mask[i]) continue;
        let all = 1;
        for (let dy = -1; dy <= 1 && all; dy++) for (let dx = -1; dx <= 1; dx++) if (!mask[i + dy * w + dx]) { all = 0; break; }
        out[i] = all;
      }
    return out;
  };
  const regionOf = (mask) => {
    let n = 0;
    let x0 = w, y0 = h, x1 = -1, y1 = -1;
    for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (mask[y * w + x]) { n++; x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
    return { n, box: n ? [x0 / SCALE, y0 / SCALE, x1 / SCALE, y1 / SCALE].map((v) => Math.round(v * 10) / 10) : null };
  };
  async function first(a, b, hide = null) {
    const A = await render(a);
    const B = await render(b, hide);
    let mask = new Uint8Array(w * h);
    for (let i = 0; i < w * h; i++) if (Math.abs(A[i] - B[i]) >= 5) mask[i] = 1;
    mask = erode(erode(mask));
    return regionOf(mask);
  }
  let cache = null;
  async function second(a, b, c) {
    const A = cache && cache.x === a ? cache.lum : await render(a);
    const B = cache && cache.y === b ? cache.mid : await render(b);
    const Cl = await render(c);
    cache = { x: b, lum: B, y: c, mid: Cl };
    let mask = new Uint8Array(w * h);
    for (let y = 1; y < h - 1; y++)
      for (let x = 1; x < w - 1; x++) {
        const i = y * w + x;
        const e = Math.abs(2 * B[i] - A[i] - Cl[i]);
        if (e <= 6) continue;
        let flat = true;
        for (let dy = -1; dy <= 1 && flat; dy++) for (let dx = -1; dx <= 1; dx++) if (Math.abs(B[i + dy * w + dx] - B[i]) > 4) { flat = false; break; }
        if (e > 56 || flat) mask[i] = 1;
      }
    mask = erode(mask);
    const region = regionOf(mask);
    if (window.__tcDump && region.n >= 6 && !window.__tcDumped) {
      window.__tcDumped = true;
      const png = (lum, scaleBy = 1) => {
        const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
        const ctx = canvas.getContext("2d");
        const img = ctx.createImageData(w, h);
        for (let i = 0; i < w * h; i++) {
          const v = Math.max(0, Math.min(255, lum[i] * scaleBy));
          img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v;
          img.data[i * 4 + 3] = 255;
        }
        ctx.putImageData(img, 0, 0);
        return canvas.toDataURL("image/png").split(",")[1];
      };
      const diff = new Float32Array(w * h);
      for (let i = 0; i < w * h; i++) diff[i] = Math.abs(2 * B[i] - A[i] - Cl[i]);
      window.__tcDumped = { a: png(A), b: png(B), c: png(Cl), e: png(diff, 4) };
    }
    return region;
  }
  window.__tc.pops = { first, second, render };
  return { w, h };
})()`;

const PERF = String.raw`(async (frames, step, group) => {
  const { C, stage } = window.__tc;
  const name = group || C.scene().groups.find((g) => g.kind === "turn")?.name;
  const base = C.values[name] || 0;
  const ms = [];
  const writes = [];
  const moved = [];
  const times = [];
  await new Promise((done) => {
    let i = 0;
    const tick = (now) => {
      times.push(now);
      const s = C.set({ [name]: base + i * step });
      ms.push(s.ms);
      writes.push(s.writes);
      moved.push(s.moved);
      i++;
      if (i < frames) requestAnimationFrame(tick);
      else done();
    };
    requestAnimationFrame(tick);
  });
  const gaps = times.slice(1).map((t, i) => t - times[i]).sort((a, b) => a - b);
  const sorted = (list) => list.slice().sort((a, b) => a - b);
  const median = (list) => sorted(list)[Math.floor(list.length / 2)];
  const p95 = (list) => sorted(list)[Math.floor(list.length * 0.95)];
  const live = [...stage.querySelectorAll("svg[data-live] *")].length;
  return { frames, setMedian: median(ms), setP95: p95(ms), writes: median(writes), moved: median(moved), gapMedian: median(gaps), gapP95: p95(gaps), live };
})`;

async function main() {
  const argv = process.argv.slice(2);
  if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
    usage();
    process.exit(argv.length ? 0 : 1);
  }
  const value = (flag, fallback) => {
    const at = argv.indexOf(flag);
    return at >= 0 ? argv[at + 1] : fallback;
  };
  const target = argv[0];
  let failed = 0;
  if (target.endsWith(".mjs")) {
    const F = await import("./turn-fidelity.mjs");
    const { T, P } = await import(pathToFileURL(resolve(target)).href);
    const angles = (value("--angles", "") || Array.from({ length: 12 }, (_, i) => 7 + i * 30).join(",")).split(",").map(Number);
    const plant = T.parts.find((part) => part.kind === "prism" && part.group && part.group.kind === "turn");
    if (plant) {
      const control = F.identity(T, P, angles.slice(0, 2), { plant: plant.name });
      console.log(`control (${plant.name} drawn 2° off): ${control.pass ? "did not fail: the check is broken" : "fails as it should"}`);
      if (control.pass) failed++;
    }
    for (const [name, run] of [["prisms", F.identity], ["rounds", F.rounds], ["tubes", F.tubes]]) {
      const result = run(T, P, angles);
      console.log(result.text);
      for (const line of result.worst) console.log(`  ${line}`);
      if (!result.pass) {
        console.log(`${name} FAILED`);
        failed++;
      }
    }
    console.log(failed ? `fidelity failed: ${failed}` : "fidelity passed");
    process.exit(failed ? 1 : 0);
  }
  const options = { width: Number(value("--width", 1440)), height: Number(value("--height", 1100)), scale: Number(value("--scale", argv.includes("--perf") ? (Number(value("--width", 1440)) < 600 ? 3 : 2) : 1)), throttle: Number(value("--throttle", 1)), noWebgl: argv.includes("--no-webgl") };
  const step = Number(value("--step", 1));
  const out = value("--out", null);
  const statesFile = value("--states", null);
  const states = statesFile ? JSON.parse(readFileSync(statesFile, "utf8")) : null;
  const page = await browser(target, options);
  const labelOf = (state, index) => (typeof state === "number" ? `${state.toFixed(3)}°` : `state ${index}`);
  try {
    const info = await page.evaluate(HELPERS);
    console.log(`page ${info.W}×${info.H} · live layers ${info.layers.filter(Boolean).join(", ")} · theme ${info.theme}`);
    await page.evaluate(`window.__isoTurn.set(0) && 0`);
    const sweep = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step);
    if (argv.includes("--order")) {
      const has = await page.evaluate("Boolean(window.__isoVerify)");
      if (!has) {
        console.log("order: this page has no window.__isoVerify; build it with --verify");
        failed++;
      } else {
        const z = await page.evaluate(ZBUFFER);
        console.log(`order: ${z.parts} live parts and ${z.statics} static items meshed, z-buffer ${z.w}×${z.h}`);
        let control = Infinity;
        for (const angle of [0, 90, 180, 270]) {
          const r = await page.evaluate(`window.__tc.order.check(${angle}, { reverse: true }).then((r) => r.live.reduce((s, p) => s + p.px, 0))`);
          control = Math.min(control, r);
        }
        console.log(`order control (each live layer reversed at 0, 90, 180, 270): least ${control} mismatched px ${control >= 1000 ? "· fails as it should" : "· did not fail: the check is broken"}`);
        if (control < 1000) failed++;
        const probe = value("--part", null);
        if (probe) for (const state of sweep.slice(0, 12)) console.log(`  ${probe} at ${JSON.stringify(state)}: ${JSON.stringify(await page.evaluate(`window.__tc.order.footprint(${JSON.stringify(state)}, ${JSON.stringify(probe)})`))}`);
        let bad = 0;
        const layerNotes = new Map();
        let worst = 0;
        for (let index = 0; index < sweep.length; index++) {
          const state = sweep[index];
          const r = await page.evaluate(`window.__tc.order.check(${JSON.stringify(state)})`);
          const errors = r.live.filter((p) => p.px >= 4);
          worst = Math.max(worst, ...r.live.map((p) => p.px), 0);
          if (errors.length) {
            bad++;
            if (bad <= 30) console.log(`  ${labelOf(state, index)}: ${errors.map((e) => `${e.painter} over ${e.truth} ${e.px} px`).join(" · ")}${r.stats.forced ? ` · forced ${r.stats.forced}` : ""}`);
          }
          for (const l of r.layers) if (l.px >= 16) layerNotes.set(`${l.painter} / ${l.truth}`, Math.max(layerNotes.get(`${l.painter} / ${l.truth}`) ?? 0, l.px));
        }
        console.log(`order: ${sweep.length} states · ${bad} with a live pair drawn out of order (4+ interior px at 2x) · worst live pair ${worst} px`);
        if (layerNotes.size) console.log(`  static × live (recorded static shapes are stand-ins; the layer proofs own these): ${[...layerNotes].slice(0, 8).map(([k, v]) => `${k} ${v}px`).join(" · ")}`);
        if (bad) failed++;
      }
    }
    if (argv.includes("--pops")) {
      await page.evaluate(POPS);
      if (out) await page.evaluate("window.__tcDump = true");
      const plantPart = await page.evaluate(`(() => { const svgs = [...document.querySelectorAll("svg[data-live]")]; const parts = [...svgs[svgs.length - 1].querySelectorAll("[data-p]")]; let best = null; let area = 0; for (const el of parts) { const b = el.getBBox(); if (b.width * b.height > area) { area = b.width * b.height; best = el; } } return best ? Number(best.dataset.p) : null; })()`);
      const plantAt = typeof sweep[0] === "number" ? 37 : sweep[0];
      const control = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(plantAt)}, ${JSON.stringify(typeof plantAt === "number" ? plantAt + 0.02 : plantAt)}, ${plantPart})`);
      console.log(`pops control (part ${plantPart} hidden for one frame): ${control.n ? `${control.n} px flagged · fails as it should` : "nothing flagged: the check is broken"}`);
      if (!control.n) failed++;
      let firstBad = 0;
      const firstStates = states ?? Array.from({ length: Math.round(360 / step) }, (_, i) => i * step);
      for (let index = 0; index < firstStates.length; index++) {
        const a = firstStates[index];
        const b = typeof a === "number" ? a + 0.02 : firstStates[index + 1];
        if (b === undefined) break;
        const r = await page.evaluate(`window.__tc.pops.first(${JSON.stringify(a)}, ${JSON.stringify(b)})`);
        if (r.n) {
          firstBad++;
          if (firstBad <= 20) console.log(`  first difference ${labelOf(a, index)}: ${r.n} px survive two erosions in ${r.box.join(",")}`);
        }
      }
      console.log(`pops, first difference: ${firstStates.length} steps · ${firstBad} with a surviving region`);
      if (firstBad) failed++;
      if (!states) {
        let cores = 0;
        const small = [];
        const fine = Number(value("--fine", 0.1));
        for (let x = 0; x + 2 * fine <= 360 + 1e-9; x += fine) {
          const r = await page.evaluate(`window.__tc.pops.second(${x.toFixed(4)}, ${(x + fine).toFixed(4)}, ${(x + 2 * fine).toFixed(4)})`);
          if (r.n >= 6) {
            cores++;
            if (cores <= 20) console.log(`  second difference at ${(x + fine).toFixed(2)}°: core of ${r.n} px in ${r.box.join(",")}`);
          } else if (r.n) small.push(`${(x + fine).toFixed(2)}° ${r.n} px in ${r.box.join(",")}`);
        }
        console.log(`pops, second difference at ${fine}° over a full turn: ${cores} cores of 6+ px · ${small.length} small cores to read${small.length ? `: ${small.slice(0, 12).join(" · ")}` : ""}`);
        const dumped = out ? await page.evaluate("window.__tcDumped && typeof window.__tcDumped === 'object' ? window.__tcDumped : null") : null;
        if (dumped) {
          mkdirSync(out, { recursive: true });
          for (const [key, data] of Object.entries(dumped)) writeFileSync(join(out, `pops-core-${key}.png`), Buffer.from(data, "base64"));
          console.log(`  first core dumped to ${out}/pops-core-{a,b,c,e}.png`);
        }
        if (cores) failed++;
      }
    }
    if (argv.includes("--lines")) {
      const angles = Array.from({ length: 24 }, (_, i) => i * 15);
      const planted = await page.evaluate(`(() => { window.__isoTurn.set(0); const part = document.querySelector("svg[data-live] [data-p]"); const el = document.createElementNS("http://www.w3.org/2000/svg", "path"); el.setAttribute("class", "iso-line"); el.setAttribute("data-tone", "lo"); el.setAttribute("d", "M5 5L25 9"); el.setAttribute("data-plant", ""); part.appendChild(el); const r = (${LINE_ENDS})({ select: null, show: false }); el.remove(); return r ? r.problems.length : -1; })()`);
      console.log(`lines control (a planted floating line): ${planted > 0 ? "fails as it should" : "nothing flagged: the check is broken"}`);
      if (!(planted > 0)) failed++;
      let bad = 0;
      for (const angle of angles) {
        const r = await page.evaluate(`(() => { window.__isoTurn.set(${angle}); const r = (${LINE_ENDS})({ select: null, show: false }); return r ? { n: r.problems.length, list: r.problems.slice(0, 4).map((p) => p.what + " " + p.end + " " + p.gap.toFixed(2) + " px at " + p.at.join(",")) } : null; })()`);
        if (r && r.n) {
          bad += r.n;
          console.log(`  ${angle}°: ${r.n} floating ends: ${r.list.join(" · ")}`);
        }
      }
      console.log(`lines: ${angles.length} angles · ${bad} floating ends`);
      if (bad) failed++;
    }
    if (argv.includes("--perf")) {
      await page.send("Performance.enable");
      const metrics = async () => Object.fromEntries((await page.send("Performance.getMetrics")).result.metrics.map((m) => [m.name, m.value]));
      const runs = [];
      for (let r = 0; r < 3; r++) {
        const before = await metrics();
        const result = await page.evaluate(`(${PERF})(240, 1.1, null)`);
        const after = await metrics();
        const per = (key) => ((after[key] - before[key]) * 1000) / result.frames;
        runs.push({ ...result, task: per("TaskDuration"), script: per("ScriptDuration"), style: per("RecalcStyleDuration"), layout: per("LayoutDuration") });
      }
      const med = (key) => runs.map((r) => r[key]).sort((a, b) => a - b)[1];
      console.log(`perf at ${options.width} px, DPR ${options.scale}, ${options.throttle}× CPU, median of 3 runs of 240 frames at 1.1°: set() ${med("setMedian").toFixed(2)} ms (p95 ${med("setP95").toFixed(2)}) · task ${med("task").toFixed(2)} ms · script ${med("script").toFixed(2)} ms · style ${med("style").toFixed(2)} ms · layout ${med("layout").toFixed(2)} ms per frame · frame interval ${med("gapMedian").toFixed(1)} ms (p95 ${med("gapP95").toFixed(1)}) · ${runs[0].live} live elements · ${med("writes")} writes and ${med("moved")} moves per frame`);
      if (out) {
        mkdirSync(out, { recursive: true });
        writeFileSync(join(out, `perf-${options.width}-${options.throttle}x.json`), JSON.stringify(runs, null, 1));
      }
    }
    console.log(page.problems.length ? page.problems.join("\n") : "console: clean");
    if (page.problems.length) failed++;
  } catch (error) {
    console.error(error);
    failed++;
  } finally {
    page.close();
  }
  console.log(failed ? `turn check failed: ${failed}` : "turn check passed");
  process.exit(failed ? 1 : 0);
}

if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
  const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
  child.on("exit", (code) => process.exit(code ?? 1));
} else {
  await main();
}
```

## scripts/turn-fidelity.mjs

```js
import * as k from "../kit/iso-kit.mjs";
import * as G from "../kit/lathe.mjs";
import { tubePieces } from "../kit/tube.mjs";
import { TURN } from "../kit/turn.mjs";

const rootOf = (group) => {
  let root = group;
  while (root && root.parent) root = root.parent;
  return root && root.kind === "turn" ? root : null;
};
const restValues = (T, theta) => Object.fromEntries(T.groups.map((g) => [g.name, g.kind === "turn" && !g.parent ? theta : g.kind === "free" ? null : 0]));

export function identity(T, P, angles, { plant = null } = {}) {
  const scene = TURN.prepare(T.data, { unit: 1 });
const subpaths = (d) => {
    const out = [];
    for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
      const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
      const pts = [];
      for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
      out.push({ pts, closed: /Z\s*$/.test(body) });
    }
    return out;
  };
  const segDist = (p, a, b) => {
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const l = dx * dx + dy * dy;
    const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
    return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
  };
  const toPoly = (pts, closed) => (pts, closed);
  void toPoly;
  const distToPoly = (p, poly, closed = true) => {
    let best = Infinity;
    const n = poly.length;
    for (let i = 0; i < (closed ? n : n - 1); i++) best = Math.min(best, segDist(p, poly[i], poly[(i + 1) % n]));
    if (n === 1) best = Math.hypot(p[0] - poly[0][0], p[1] - poly[0][1]);
    return best;
  };
  const oneWay = (A, B) => {
    let worst = 0;
    for (const p of A) {
      let near = null;
      let gap = Infinity;
      for (const q of B) {
        const g = Math.hypot(p[0] - q[0], p[1] - q[1]);
        if (g < gap) {
          gap = g;
          near = q;
        }
      }
      worst = Math.max(worst, gap < 0.15 ? Math.max(Math.abs(p[0] - near[0]), Math.abs(p[1] - near[1])) : distToPoly(p, B));
    }
    return worst;
  };
  const hausdorff = (A, B) => Math.max(oneWay(A, B), oneWay(B, A));
  const applyMatrix = (pts, t) => {
    const v = (t.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
    return pts.map(([x, y]) => [v[0] * x + v[2] * y + v[4], v[1] * x + v[3] * y + v[5]]);
  };
  const levelOf = (score, bands) => {
    let u = 0;
    for (const t of bands) {
      const x = Math.min(1, Math.max(0, (score - (t - 0.035)) / 0.07));
      u += x * x * (3 - 2 * x);
    }
    return u;
  };
  
  
const results = { outline: 0, top: 0, faces: 0, compared: 0, skipped: 0, missing: 0, crease: 0 };
const worstAt = {};
for (const theta of angles) {
  const cams = TURN.cams(scene, restValues(T, theta));
  const Pp = { ...P, azimuth: P.azimuth - theta };
  for (const part of T.parts) {
    if (part.kind !== "prism" || !rootOf(part.group) || part.group.kind === "free") continue;
    const planted = plant && part.name === plant;
    const run = scene.runs[part.id];
    const cam = part.group ? cams[part.group.index] : cams.world;
    const f = TURN.emit(run, cam, TURN.frame(run));
    let root = part.group;
    while (root && root.parent) root = root.parent;
    const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
    const a = k.iso(pivot, P);
    const b = k.iso(pivot, Pp);
    const shift = [a[0] - b[0], a[1] - b[1]];
    const sh = (pts) => pts.map(([x, y]) => [x + shift[0], y + shift[1]]);
    const { F, ring, s0, s1 } = part.world;
    const up = run.up;
    const Pk = planted ? { ...Pp, azimuth: Pp.azimuth - 2 } : Pp;
    const kit = up ? k.extrude(ring, s0, s1 - s0, Pk) : G.prismOf(F, ring, s0, s1, Pk);
    const mine = subpaths(f.d[run.role.edge])[0].pts;
    const theirs = sh(subpaths(kit.fill)[0].pts);
    const ho = hausdorff(mine, theirs);
    results.outline = Math.max(results.outline, ho);
    if (ho > (worstAt[part.name]?.outline ?? -1)) worstAt[part.name] = { ...(worstAt[part.name] ?? {}), outline: ho, at: theta };
    if (up) {
      const topMine = applyMatrix(subpaths(part.planPath)[0].pts, f.t[run.role.top]);
      const topKit = sh(subpaths(kit.top)[0].pts);
      results.top = Math.max(results.top, hausdorff(topMine, topKit));
    }
    const kitFaces = kit.shades.map((d) => subpaths(d)).concat(up ? [] : [subpaths(kit.top)]);
    const faceCount = up ? run.n : run.n + 2;
    for (let i = 0; i < faceCount; i++) {
      const role = run.role[`f${i}`];
      const d = f.d[role];
      if (!d) continue;
      const q = f.q[role];
      if (q % TURN.q !== 0) {
        results.skipped++;
        continue;
      }
      const tone = q / TURN.q;
      const pts = subpaths(d)[0].pts;
      let best = Infinity;
      for (const sp of kitFaces[tone] ?? []) best = Math.min(best, hausdorff(pts, sh(sp.pts)));
      if (!Number.isFinite(best)) {
        results.missing++;
        continue;
      }
      results.compared++;
      results.faces = Math.max(results.faces, best);
    }
  }
}

  const limit = 0.06;
  const pass = results.outline <= limit && results.top <= limit && results.faces <= limit && results.missing === 0;
  const worst = Object.entries(worstAt).filter(([, w]) => w.outline > limit).map(([name, w]) => `${name} outline ${w.outline.toFixed(3)} at ${w.at}°`);
  return { pass, text: `prisms at ${angles.length} angles: outline ${results.outline.toFixed(4)} px · top ${results.top.toFixed(4)} px · faces ${results.faces.toFixed(4)} px over ${results.compared} faces (${results.skipped} in crossfade windows, ${results.missing} with no kit face of that tone)`, worst };
}

export function rounds(T, P, angles, { only = null } = {}) {
  const scene = TURN.prepare(T.data, { unit: 1 });
  const CELL = 0.1;
const subpaths = (d) => {
    const out = [];
    for (const [, body] of (d ?? "").matchAll(/M([^M]*)/g)) {
      const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
      const pts = [];
      for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
      out.push(pts);
    }
    return out;
  };
  const segDist = (p, a, b) => {
    const dx = b[0] - a[0];
    const dy = b[1] - a[1];
    const l = dx * dx + dy * dy;
    const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
    return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
  };
  const distToPoly = (p, poly) => {
    let best = Infinity;
    for (let i = 0; i < poly.length; i++) best = Math.min(best, segDist(p, poly[i], poly[(i + 1) % poly.length]));
    return best;
  };
  const hausdorff = (A, B) => {
    let worst = 0;
    for (const p of A) worst = Math.max(worst, distToPoly(p, B));
    for (const p of B) worst = Math.max(worst, distToPoly(p, A));
    return worst;
  };
  function raster(layers, box, grow = 0) {
    const [x0, y0, x1, y1] = box;
    const w = Math.ceil((x1 - x0) / CELL) + 1;
    const h = Math.ceil((y1 - y0) / CELL) + 1;
    const out = new Int8Array(w * h).fill(-1);
    for (const { tone, d } of layers) {
      if (!d) continue;
      const mask = new Uint8Array(w * h);
      const polys = subpaths(d);
      for (let row = 0; row < h; row++) {
        const y = y0 + row * CELL + CELL / 2;
        const hits = [];
        for (const poly of polys)
          for (let i = 0; i < poly.length; i++) {
            const a = poly[i];
            const b = poly[(i + 1) % poly.length];
            if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) {
              const x = a[0] + ((y - a[1]) / (b[1] - a[1])) * (b[0] - a[0]);
              hits.push([x, b[1] > a[1] ? 1 : -1]);
            }
          }
        hits.sort((p, q) => p[0] - q[0]);
        let wind = 0;
        for (let i = 0; i < hits.length - 1; i++) {
          wind += hits[i][1];
          if (!wind) continue;
          const from = Math.max(0, Math.ceil((hits[i][0] - x0 - CELL / 2) / CELL));
          const to = Math.min(w - 1, Math.floor((hits[i + 1][0] - x0 - CELL / 2) / CELL));
          for (let c = from; c <= to; c++) mask[row * w + c] = 1;
        }
      }
      const reach = Math.round(grow / CELL);
      for (let row = 0; row < h; row++)
        for (let c = 0; c < w; c++) {
          let on = mask[row * w + c];
          for (let dy = -reach; dy <= reach && !on; dy++)
            for (let dx = -reach; dx <= reach && !on; dx++) {
              if (dx * dx + dy * dy > reach * reach) continue;
              const rr = row + dy;
              const cc = c + dx;
              if (rr >= 0 && cc >= 0 && rr < h && cc < w && mask[rr * w + cc]) on = 1;
            }
          if (on) out[row * w + c] = tone;
        }
    }
    return { out, w, h };
  }
  
const stats = { outline: 0, outlineFine: 0, bands: 0, cells: 0, mismatched: 0, parts: 0 };
const worst = [];
for (const theta of angles) {
  const cams = TURN.cams(scene, restValues(T, theta));
  const Pp = { ...P, azimuth: P.azimuth - theta };
  const Pf = { ...Pp, origin: [Pp.origin[0] * 10, Pp.origin[1] * 10], scale: Pp.scale * 10 };
  for (const part of T.parts) {
    if ((part.kind !== "round" && part.kind !== "lathe") || (only && !only.test(part.name)) || !rootOf(part.group) || part.group.kind === "free") continue;
    const run = scene.runs[part.id];
    const cam = part.group ? cams[part.group.index] : cams.world;
    const f = TURN.emit(run, cam, TURN.frame(run));
    let root = part.group;
    while (root && root.parent) root = root.parent;
    const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
    const a = k.iso(pivot, P);
    const b = k.iso(pivot, Pp);
    const shift = [a[0] - b[0], a[1] - b[1]];
    let F, s0, s1, r0, r1, ends, profile;
    if (part.kind === "lathe") {
      const { curve, knots } = part.world;
      F = part.world.F;
      s0 = curve.s0;
      s1 = curve.s1;
      r0 = curve.radius(s0);
      r1 = curve.radius(s1);
      ends = ["flat", "flat"];
      profile = [[s0, r0], ...knots.slice(1, -1).map((sv) => [sv, curve.radius(sv), 1]), [s1, r1]];
    } else ({ F, s0, s1, r0, r1, ends, profile } = part.world);
    const meridian = [[profile[0][0], 0], ...profile.filter((p) => p[1] > 1e-9), [profile[profile.length - 1][0], 0]];
    const domed = ends.includes("dome");
    const radius = (s) => {
      if (ends[0] === "dome" && s < s0) return Math.sqrt(Math.max(0, r0 * r0 - (s - s0) ** 2));
      if (ends[1] === "dome" && s > s1) return Math.sqrt(Math.max(0, r1 * r1 - (s - s1) ** 2));
      return r0 + ((r1 - r0) * (Math.max(s0, Math.min(s1, s)) - s0)) / (s1 - s0);
    };
    const slope = (s) => {
      if (ends[0] === "dome" && s < s0) return -(s - s0) / Math.max(1e-4, radius(s));
      if (ends[1] === "dome" && s > s1) return -(s - s1) / Math.max(1e-4, radius(s));
      return (r1 - r0) / (s1 - s0);
    };
    const lo = ends[0] === "dome" ? s0 - r0 : s0;
    const hi = ends[1] === "dome" ? s1 + r1 : s1;
    const knots = [];
    for (let i = 0; i <= 64; i++) knots.push(lo + ((hi - lo) * i) / 64);
    const analytic = [[lo, 0], ...knots.slice(1, -1).map((sv) => [sv, radius(sv), 1]), [hi, 0]];
    if (ends[0] !== "dome") analytic.splice(1, 0, [lo, r0]);
    if (ends[1] !== "dome") analytic.splice(analytic.length - 1, 0, [hi, r1]);
    const lathed = part.kind === "lathe" ? { slope: part.world.curve.slope, radius: part.world.curve.radius } : null;
    const kit = lathed ? G.lathe(meridian, F, Pp, { smooth: lathed }) : domed ? G.lathe(analytic, F, Pp, { smooth: { slope, radius } }) : G.lathe(meridian, F, Pp, { smooth: true });
    const kitFine = lathed ? G.lathe(meridian, F, Pf, { smooth: lathed }) : domed ? G.lathe(analytic, F, Pf, { smooth: { slope, radius } }) : G.lathe(meridian, F, Pf, { smooth: true });
    const sh = (pts) => pts.map(([x, y]) => [x + shift[0], y + shift[1]]);
    const mine = subpaths(f.d[run.role.edge])[0];
    const theirs = sh(subpaths(kit.fill)[0]);
    const fineRef = subpaths(kitFine.fill)[0].map(([x, y]) => [x / 10 + shift[0], y / 10 + shift[1]]);
    const ho = hausdorff(mine, theirs);
    const hf = hausdorff(mine, fineRef);
    stats.outline = Math.max(stats.outline, ho);
    stats.outlineFine = Math.max(stats.outlineFine, hf);
    stats.parts++;
    const ours = [0, 1, 2, 3, 4].map((t) => ({ tone: t, d: f.d[run.role[`s${t}`]] }));
    for (const end of [0, 1]) {
      const role = run.role[`k${end}`];
      if (role === undefined || !f.d[role]) continue;
      const q = f.q[role];
      ours.push({ tone: q % TURN.q === 0 ? q / TURN.q : 9, d: f.d[role] });
    }
    const theirsLayers = [...kitFine.shades.map((d, t) => ({ tone: t, d })), { tone: 4, d: kitFine.top }].map(({ tone, d }) => ({ tone, d: subpaths(d).map((poly) => "M" + poly.map(([x, y]) => `${x / 10 + shift[0]} ${y / 10 + shift[1]}`).join("L") + "Z").join("") }));
    let x0 = Infinity;
    let y0 = Infinity;
    let x1 = -Infinity;
    let y1 = -Infinity;
    for (const [x, y] of mine) {
      x0 = Math.min(x0, x);
      y0 = Math.min(y0, y);
      x1 = Math.max(x1, x);
      y1 = Math.max(y1, y);
    }
    const box = [x0 - 1, y0 - 1, x1 + 1, y1 + 1];
    const A = raster(ours, box, 0.2);
    const B = raster(theirsLayers, box, 0.2);
    const inside = raster([{ tone: 1, d: f.d[run.role.edge] }], box).out;
    const capCells = raster([0, 1].map((end) => ({ tone: 1, d: run.role[`k${end}`] !== undefined ? f.d[run.role[`k${end}`]] : "" })), box, 0.6).out;
    const capExempt = part.kind === "lathe";
    const insideKit = raster([{ tone: 1, d: "M" + fineRef.map(([x, y]) => `${x} ${y}`).join("L") + "Z" }], box).out;
    let worstGap = 0;
    const reach = Math.ceil(0.5 / CELL) + 2;
    for (let row = 0; row < A.h; row++)
      for (let c = 0; c < A.w; c++) {
        const i = row * A.w + c;
        if (inside[i] < 0 || insideKit[i] < 0) continue;
        const ta = A.out[i];
        const tb = B.out[i];
        if (ta === 9 || ta < 0 || tb < 0) continue;
        if (capExempt && capCells[i] >= 0) continue;
        stats.cells++;
        if (ta === tb) continue;
        let edge = false;
        for (let dy = -3; dy <= 3 && !edge; dy++) for (let dx = -3; dx <= 3; dx++) if (inside[(row + dy) * A.w + c + dx] < 0) edge = true;
        if (edge) continue;
        stats.mismatched++;
        let near = Infinity;
        for (let dy = -reach; dy <= reach; dy++)
          for (let dx = -reach; dx <= reach; dx++) {
            const rr = row + dy;
            const cc = c + dx;
            if (rr < 0 || cc < 0 || rr >= A.h || cc >= A.w) continue;
            if (B.out[rr * A.w + cc] === ta) near = Math.min(near, Math.hypot(dx, dy) * CELL);
          }
        worstGap = Math.max(worstGap, near);
      }
    stats.bands = Math.max(stats.bands, worstGap);
    if (worstGap > 0.5 || hf > 0.05) worst.push({ part: part.name, theta, band: worstGap, outline: hf });
  }
}

  return { pass: stats.outlineFine <= 0.05 && stats.bands <= 0.5, text: `rounds: ${stats.parts} frames at ${angles.length} angles: outline vs kit ${stats.outline.toFixed(4)} px (kit rounds to 0.1), vs kit at 10x ${stats.outlineFine.toFixed(4)} px · band boundaries ≤ ${stats.bands.toFixed(2)} px apart (${stats.mismatched} of ${stats.cells} interior cells differ)`, worst: worst.sort((a, b) => b.band - a.band || b.outline - a.outline).slice(0, 12).map((w) => `${w.part} at ${w.theta}°: outline ${w.outline.toFixed(3)} px, band ${w.band.toFixed(2)} px`) };
}

export function tubes(T, P, angles) {
  const scene = TURN.prepare(T.data, { unit: 1 });
const subpaths = (d) => [...(d ?? "").matchAll(/M([^M]*)/g)].map(([, body]) => {
    const nums = (body.match(/-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? []).map(Number);
    const pts = [];
    for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
    return pts;
  });
  const segDist = (p, a, b) => {
    const dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy;
    const t = l ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l)) : 0;
    return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
  };
  const compare = (mine, theirs) => {
    const A = subpaths(mine);
    const B = subpaths(theirs);
    if (A.length !== B.length) return { error: Infinity, why: `subpaths ${A.length} vs ${B.length}` };
    let worst = 0;
    for (let i = 0; i < A.length; i++) {
      if (A[i].length === B[i].length) for (let j = 0; j < A[i].length; j++) worst = Math.max(worst, Math.abs(A[i][j][0] - B[i][j][0]), Math.abs(A[i][j][1] - B[i][j][1]));
      else for (const p of A[i]) worst = Math.max(worst, Math.min(...B[i].slice(1).map((q, j) => segDist(p, B[i][j], q))));
    }
    return { error: worst };
  };
  
const stats = { body: 0, shine: 0, shade: 0, edges: 0, rings: 0, chunks: 0 };
const bad = [];
for (const theta of angles) {
  const cams = TURN.cams(scene, restValues(T, theta));
  const Pp = { ...P, azimuth: P.azimuth - theta };
  const Pf = { ...Pp, origin: [Pp.origin[0] * 10, Pp.origin[1] * 10], scale: Pp.scale * 10 };
  for (const route of T.routes) {
    if (route.group && (!rootOf(route.group) || route.group.kind === "free")) continue;
    const root = rootOf(route.group);
    const pivot = root ? [root.origin[0], root.origin[1], 0] : [0, 0, 0];
    const a = k.iso(pivot, P);
    const b = k.iso(pivot, Pp);
    const shift = [a[0] - b[0], a[1] - b[1]];
    const kit = tubePieces(route.world, route.r, Pf, { maxLength: route.chunk, spacing: route.spacing, gaps: route.gaps, breaks: route.breaks, caps: route.caps, stripes: route.wide, rings: route.rings });
    const scaled = (d) => subpaths(d).map((pts) => "M" + pts.map(([x, y]) => `${x / 10 + shift[0]} ${y / 10 + shift[1]}`).join("L")).join("");
    const chunks = T.parts.filter((part) => part.route === route.name);
    if (kit.length !== chunks.length) {
      bad.push(`${route.name}: ${chunks.length} chunks vs kit ${kit.length}`);
      continue;
    }
    chunks.forEach((part, index) => {
      const run = scene.runs[part.id];
      const f = TURN.emit(run, route.group ? cams[route.group.index] : cams.world, TURN.frame(run));
      const piece = kit[index];
      stats.chunks++;
      for (const key of ["body", "shine", "shade", "edges"]) {
        if (run.role[key] === undefined) continue;
        const { error, why } = compare(f.d[run.role[key]], scaled(piece[key]));
        stats[key] = Math.max(stats[key], error);
        if (error > 0.05) bad.push(`${part.name} ${key} at ${theta}°: ${why ?? error.toFixed(3)}`);
      }
      if (route.rings) {
        const mine = run.roles.filter((role) => role.name.startsWith("r")).map((role) => f.d[run.role[role.name]]).join("");
        const theirs = scaled(piece.rings.filter((ring) => ring.alpha * 32 >= 0.5).map((ring) => ring.d).join(""));
        const A = subpaths(mine);
        const B = subpaths(theirs);
        let worst = A.length === B.length ? 0 : Infinity;
        for (const seg of A) {
          let best = Infinity;
          for (const other of B) best = Math.min(best, Math.max(...seg.map((p, j) => Math.hypot(p[0] - other[j][0], p[1] - other[j][1]))));
          worst = Math.max(worst, best);
        }
        stats.rings = Math.max(stats.rings, worst);
        if (worst > 0.05) bad.push(`${part.name} rings at ${theta}°: ${A.length} vs ${B.length} segments, worst ${worst.toFixed(3)}`);
      }
    });
  }
}

  return { pass: !bad.length, text: `tubes: ${stats.chunks} chunk frames at ${angles.length} angles against tubePieces at 10x: body ${stats.body.toFixed(4)} · shine ${stats.shine.toFixed(4)} · shade ${stats.shade.toFixed(4)} · edges ${stats.edges.toFixed(4)} · rings ${stats.rings.toFixed(4)} px`, worst: bad.slice(0, 20) };
}
```

