{"owner":"earthtojake","repo":"text-to-cad","hasSkills":true,"totalSkillsCount":96,"totalTokensCount":180615,"categories":["marketplace","plugin-manifest","root-instruction","anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":[".claude-plugin/marketplace.json",".claude-plugin/plugin.json",".codex-plugin/plugin.json",".cursor-plugin/plugin.json","AGENTS.md","apps/web/skills/smui/SKILL.md","packages/README.md","packages/cadgen/README.md","packages/core/README.md","packages/core/docs/render-pipeline.md","packages/core/docs/resource-ownership.md","packages/core/docs/tube-deformation.md","packages/core/docs/workspace-resources.md","packages/ui/README.md","packages/ui/docs/annotation.md","packages/ui/docs/cad-renderer.md","packages/ui/docs/feature-detection.md","packages/ui/docs/file-viewer.md","packages/ui/docs/lod.md","packages/ui/docs/render-mode.md","packages/ui/docs/render-types.md","packages/ui/docs/renderers.md","packages/ui/docs/settings-ui.md","packages/ui/docs/view-updates.md","packages/ui/docs/viewer-host.md","packages/ui/src/assets/README.md","packages/ui/src/renderers/dxf/__fixtures__/README.md","packages/ui/src/renderers/step/__fixtures__/step/README.md","plugin.json","skills/bambu-labs/SKILL.md","skills/bambu-labs/agents/openai.yaml","skills/cad/SKILL.md","skills/cad/agents/openai.yaml","skills/cad/references/animation-deformation.md","skills/cad/references/build123d-modeling.md","skills/cad/references/cad-brief.md","skills/cad/references/inspection-and-validation.md","skills/cad/references/kinematics.md","skills/cad/references/migrations.md","skills/cad/references/positioning.md","skills/cad/references/project-layout.md","skills/cad/references/project-template.md","skills/cad/references/repair-loop.md","skills/cad/references/snapshot-review.md","skills/cad/references/step-generation.md","skills/cad/references/supported-exports.md","skills/dfam-check/SKILL.md","skills/dfam-check/agents/openai.yaml","skills/dfam-check/references/process-limits.md","skills/dfm/SKILL.md","skills/dfm/agents/openai.yaml","skills/dfm/references/cnc.md","skills/dfm/references/injection-molding.md","skills/dfm/references/sheet-metal.md","skills/dxf/SKILL.md","skills/dxf/agents/openai.yaml","skills/dxf/references/generator-templates.md","skills/engineering-drawing/SKILL.md","skills/engineering-drawing/agents/openai.yaml","skills/engineering-drawing/references/sheet-api.md","skills/gcode/SKILL.md","skills/gcode/agents/openai.yaml","skills/sdf/SKILL.md","skills/sdf/agents/openai.yaml","skills/sdf/references/design-ledger.md","skills/sdf/references/examples.md","skills/sdf/references/frame-semantics.md","skills/sdf/references/interoperability.md","skills/sdf/references/llm-guardrails.md","skills/sdf/references/sdf-workflow.md","skills/sdf/references/smoke-tests.md","skills/sdf/references/validation.md","skills/sendcutsend/SKILL.md","skills/sendcutsend/agents/openai.yaml","skills/sendcutsend/references/official-sources.md","skills/sendcutsend/references/report-template.md","skills/srdf/SKILL.md","skills/srdf/agents/openai.yaml","skills/srdf/references/authoring-contract.md","skills/srdf/references/disabled-collisions.md","skills/srdf/references/end-effectors.md","skills/srdf/references/planning-ledger.md","skills/srdf/references/srdf-workflow.md","skills/srdf/references/validation.md","skills/step-parts/SKILL.md","skills/step-parts/agents/openai.yaml","skills/step-parts/references/step-parts-api.md","skills/urdf/SKILL.md","skills/urdf/agents/openai.yaml","skills/urdf/references/authoring-contract.md","skills/urdf/references/design-ledger.md","skills/urdf/references/frame-semantics.md","skills/urdf/references/inertials.md","skills/urdf/references/meshes.md","skills/urdf/references/urdf-workflow.md","skills/urdf/references/validation.md"],"skills":{".claude-plugin/marketplace.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fmarketplace.json -->",".claude-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fplugin.json -->",".codex-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.codex-plugin%2Fplugin.json -->",".cursor-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.cursor-plugin%2Fplugin.json -->","AGENTS.md":"# AGENTS.md\n\nInstalling text-to-cad rather than developing it? Follow the Install section of\n[README.md](README.md).\n\nThis repo is a workbench for CAD-related agent skills. Treat `skills/` as the\nproduct and `models/` as the shared fixture/artifact area.\n\n## Branch First\n\n`main` is the only branch you develop on: the source tree, and what releases\nare cut from. Branch from `main` and open PRs against `main`; never push it\ndirectly. There is no development symlink layout — every path in the tree is the\nreal file. Installers follow `latest`, and claude.ai's directory\n`claude-plugin`: the plugin alone, one commit per release,\nwritten only by `Publish Release` once the release is on PyPI (see below); never\ncommit to them. `main` stays an installable plugin too: every manifest and MCP\nconfig lives at its root.\n\n## Release Workflow\n\nA pull request that changes `VERSION` is a release, and merging it releases it.\nNever bump during normal development work, and never pick the bump yourself: if\na request to make or ship a release does not name patch, minor, major, or an\nexact version, ask which one. A release pull request comes one of two ways:\n\n- **Its own pull request**, for a release of what `main` already has — what\n  \"make a release\" means unless the user names a pull request to carry it:\n  dispatch `Prepare Release`\n  (`gh workflow run release-prepare.yml --ref main -f bump=<patch|minor|major>`,\n  or `-f set_version=X.Y.Z`). It opens `release/X.Y.Z` with the bump and never\n  merges it.\n- **On the pull request that should carry it**: on its branch,\n  `scripts/release/bump-version.sh <patch|minor|major|X.Y.Z>` sets `VERSION` past\n  `main`'s and stamps the derived metadata and every `cadgen==` pin with it.\n\nMerge a release pull request only on the user's explicit word, once its checks\npass: the merge is the release.\n\n- `Test` runs every job for a `VERSION` change, so a release is tested whole.\n  Its Version Check vets the bump: a branch of this repository only (a fork\n  cannot release), a version past `main`'s and the latest tag, every stamp in\n  step.\n- `Publish Release` (`release-publish.yml`) runs on the merge. It finds the\n  `Test` run that recorded the merged tree as tested in full (else it runs every\n  `Test` job on it first), builds the plugin ZIP, the bundle and the `cadgen`\n  wheel and sdist, installs and exercises the wheel, uploads the pages' source\n  maps to PostHog and the wheel to PyPI, and\n  waits until PyPI's index lists it. Only then does it commit the plugin alone\n  onto the branches installers follow (`scripts/release/plugin_branch.py`):\n  `latest`, which every install command names, and `claude-plugin`, which\n  claude.ai's directory tracks. After them it deploys the docs site and tags\n  (`v<VERSION>`; releases before 0.5.0 are bare\n  `0.4.x` tags) and GitHub-Releases the release commit, with the wheel and sdist\n  that went to PyPI attached as release assets, plus the plugin ZIP that a\n  person uploads to OpenAI's plugin portal, which has no API.\n\nTo finish a run that stopped partway, dispatch `Publish Release` on `main`\n(`publish=false` leaves the GitHub Release as a draft): it publishes the commit\nthat last moved `VERSION`, if that version has no tag yet. `build-test` is the\nrehearsal branch — `Prepare Release` with `-f target=build-test` opens a bump\nagainst it, whose merge is built and installed with no PyPI, branches, docs or\ntag — and is never a release; use it only when the user explicitly asks to test\nthe pipeline.\n\nThe standalone `Deploy Docs` workflow redeploys the docs site from a ref\n(default `main`, or a release tag) without running a release.\n\ncadgen runs as ONE launch command everywhere, `uvx --no-config --managed-python\n--python 3.13 --from cadgen==<VERSION> <tool>` (`cadgen._internal.launch`): the\nplugin server configs and every cadgen skill's SKILL.md carry it, the release\nstamps every pin (`sync-version.mjs`), and `scripts/release/check-version.sh`\nand the tests assert they all equal `VERSION`. uv keeps one installation per\nrequirement and cadgen names its warm daemon after that installation, so the\nserver, every thread and every skill command share one daemon. In a checkout,\ninstall `requirements-dev.txt` (the editable cadgen) and test in agent apps with\n`scripts/install/dev_install.py`, which points both at the checkout.\n`models/` stays on `main` as plain files; nothing installs it.\n`scripts/github-workflows/check-builds.sh` enforces the shipping contract on\nevery push: no tracked symlink, no `.gitattributes` rule that rewrites files at\ncheckout or changes the archive (so no Git LFS; claude.ai's plugin directory\nrefuses them), every tracked file under 5 MiB, no skill reaching into a repo root.\nSee the Releases section in `CONTRIBUTING.md` for the full flow, the resume\npath, the rehearsal, and local/manual fallbacks.\n\n## Repo Map\n\n- `skills/`: agent skills and their references/scripts.\n- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, and the\n  [Agent Plugins](https://agent-plugins.org) standard's `plugin.json` and `mcp.json`:\n  agent plugin manifests. The repository root is the plugin package; its skills are\n  `skills/` directly. Installers take it from the `latest` branch, which\n  `scripts/release/plugin_branch.py` builds from this tree at each release.\n- `models/`: sample and durable CAD/robot-description fixtures.\n- `apps/web/`: the CAD Viewer's React client (its backend is `cadgen.viewer`).\n- `apps/mcp/`: the CAD app agent hosts render: tabs in Codex, cards in Claude Desktop (its server is `cadgen mcp`).\n- `packages/core`: `@text-to-cad/core`, shared CAD/runtime/client code without React.\n- `packages/ui`: `@text-to-cad/ui`, the shared FileViewer, renderers, controls and styles.\n- `packages/cadgen`: the published distribution — STEP/GLB/topology generation,\n  the skill CLI parsers, the CAD Viewer backend + client, and the Node/browser\n  runtimes it executes.\n- `apps/docs/`: documentation site.\n- `apps/api/`: `api.texttocad.dev`, its own Vercel project: cadgen's version feed (read from PyPI) and the CAD app's consented analytics.\n- `tests/`: root-owned test suites for skills, packages, viewer services, and\n  repo-wide policy.\n- `scripts/`: durable repo commands grouped by purpose.\n\n## Repo Rules\n\n- Boundaries and design laws live in each package's README: read\n  `packages/cadgen/README.md` (the laws), `packages/core/README.md`, `packages/ui/README.md`,\n  `apps/web/README.md`, `apps/docs/README.md` and `apps/api/README.md` before changing\n  generation, rendering, storage, layout, or public interfaces.\n- A README holds the laws; the mechanism each law constrains lives one link\n  away, and the README names the link. Read the README, then follow the one\n  link — not the tree. What exists:\n  - `packages/cadgen/`: `STORE.md` (the store contract — sectioned, with a\n    table of contents), `SNAPSHOTS.md` (snapshot `--debug` timings).\n  - `packages/core/docs/`: `render-pipeline.md`, `resource-ownership.md`,\n    `tube-deformation.md`.\n  - `packages/ui/docs/`: `settings-ui.md` (BINDING for any settings control),\n    `render-types.md`, `render-mode.md`, `lod.md`, `storage.md`, `backend.md`.\n- Ships-alone law: `packages/cadgen` (the built PyPI wheel) works in isolation\n  outside this repo, so its markdown must not refer to anything outside the\n  package — enforced by `tests/python/global/test_package_boundaries.py`.\n  Repo-development guidance for it goes in `CONTRIBUTING.md`.\n\n- The README and the docs site's homepage (its copy: `apps/docs/src/lib/content.ts`)\n  share their structure, install message and commands: change them together.\n  The README says more: the steps to install it yourself, and fuller notes under each install\n  (`apps/docs/README.md`).\n- Keep root guidance short. Put domain workflows, CLI details, and validation\n  policy in the relevant `skills/<skill>/SKILL.md` or `references/` file.\n- Keep relevant Markdown docs current when changing behavior, commands, or repo\n  layout, but do not bloat `AGENTS.md`; use it only for durable repo-level\n  rules and pointers.\n- Read `CONTRIBUTING.md` before committing, rebasing, resolving generated-file\n  conflicts, or bumping release versions.\n- A skill must not import another skill, a `skills/` root module, or a\n  repository-root module, and must not add `skills/`, the repository root, or a\n  sibling skill directory to `sys.path`, `PYTHONPATH`, `NODE_PATH`, or any other\n  runtime lookup path. Skills are independent of each other, not of everything.\n- Shared runtime comes from the **`cadgen` distribution**. A skill that uses it\n  defines `cadgen` and `python` in its SKILL.md as the launch command above,\n  pinned to `VERSION` (the release stamps every pin); no skill installs cadgen\n  any other way (no `requirements.txt` naming it), because a second installation\n  is a second daemon. Skills do not vendor it: a skill script is a thin entrypoint whose\n  parser and behaviour live in `cadgen.cli`. Playwright is a cadgen dependency and\n  the first snapshot fetches its headless browser, so no skill has a browser step. cadgen carries\n  the JavaScript it executes too (Node builders, the snapshot browser bundle,\n  the CAD Viewer client), so a skill ships no runtime of its own. Not every\n  skill needs cadgen (bambu-labs, dfam-check, dfm, gcode, sendcutsend,\n  step-parts are cadgen-free); do not add the dependency to a skill that never invokes it.\n- Keep samples and manual CAD/robot-description validation artifacts under\n  `models/`. Automated tests must not read, build or import that sample corpus:\n  generate small fixtures in fresh temporary directories or use tiny test-owned\n  fixtures, with their own cache stores and cleanup. Repo `tmp/` is fine.\n  Enforced by `tests/python/global/test_tests_are_self_contained.py`.\n- Every test runs in CI. Each test file is reached by a runner under\n  `scripts/test/`, every runner is called by a `test.yml` job on the changes that\n  can break it, and a collector that finds nothing fails the run rather than\n  reporting a group that never ran. A test no CI job runs is dead: wire it in or\n  delete it. There are no manual-only test gates. Enforced by\n  `tests/python/global/test_ci_workspace_selection.py`.\n- Tests and CI are short and succinct. Test a contract, a user flow or a fixed bug,\n  once, at the cheapest level that exercises the real path: a unit or jsdom test\n  first, a real browser (WebGL) only for what needs one. Await the condition, never\n  a fixed sleep or a wall-clock bound; a flaky test is fixed or deleted, never\n  retried. CI time is a budget: the `web` job stays within 7 minutes, and a\n  change that lengthens any job says what it costs and why in its PR.\n- Benchmarks under `scripts/bench/` are manual and their output is never\n  committed: reports, logs, profiles and screenshots go to an ignored `tmp/`.\n  Only their pure helper units run in a test runner.\n- The Python floor is `requires-python` in `packages/cadgen/pyproject.toml` and\n  nowhere else. Every cadgen source is parsed against that floor, so syntax\n  newer than it fails here rather than at `pip install` time on a user's\n  interpreter; raising the declared minimum relaxes the check automatically.\n- Reserve `scripts/` for durable repo commands. Do not write temporary,\n  one-off, or local-only helper scripts there; use `tmp/` or `/tmp` instead.\n- cadgen's packaged runtime (`_runtime/node`, `_runtime/browser`,\n  `_runtime/viewer`, and the file tracer every build loads, `_runtime/native`)\n  is BUILT, never committed: the whole directory is\n  gitignored and ships only inside the wheel. Build it with the one bundle\n  entry point, `scripts/bundle/bundle.sh`; `bundle.sh --check` builds it and\n  asserts every required output. Call `scripts/bundle/cadgen-runtime.sh`\n  directly only when debugging one stage.\n- Installs always work from `main`. A command that names no branch installs `main`\n  as it is, so every manifest, MCP config and the marketplace catalog stay at its\n  root, the catalog lists the plugin at `./`, and nothing on `main` points an\n  installer at another branch. The documented commands name `latest` instead: the\n  plugin alone, one commit per release, which `Publish Release` writes once the\n  release is on PyPI, so it installs lean (no monorepo, no npm install of the\n  workspace) and is never ahead of PyPI. `latest` is a channel, so a slower\n  `stable` can join it later. `tests/python/global/test_plugin_manifests.py`\n  holds both halves.\n- Never let a symlink reach the published tree. Agent installers disagree about\n  symlinks and one loses data silently: the Skills CLI dereferences them, Claude\n  Code preserves them, and Codex `plugin add` drops them with no error, shipping\n  a skill with missing files. `scripts/github-workflows/check-builds.sh` enforces\n  this; do not relax it.\n- The CAD Viewer is `cadgen viewer`: the server is `cadgen.viewer` (Python, in\n  `packages/cadgen`), the React client's source is `apps/web/` and its build\n  ships in the wheel at `cadgen/_runtime/viewer` (built, never committed; a\n  checkout serves `apps/web/dist`). The CAD, DXF and robot-description skills document that verb directly.\n  Nothing in `cadgen.viewer` imports the CAD kernel at module scope — the one\n  kernel action, importing a foreign STEP, is a compile job in cadgen's build\n  pool, never work the server process does.\n  Keep repo-level tooling in `scripts/`, not under `apps/web/`.\n- `packages/core` stays non-React. Shared FileViewer/renderers belong in\n  `packages/ui`; host workflow state belongs in apps. Apps never import another\n  app and shared packages never import apps. Root npm workspaces consume compiled\n  package exports; do not add source aliases or nested lockfiles. Preserve app\n  UI/UX and functionality during restructuring; changes are pure refactors.\n- Shared UI must stay platform-agnostic. Apps implement environmental effects;\n  shared features use injected capabilities and named extension slots. Before\n  extending these interfaces, read [the viewer host contract](packages/ui/docs/viewer-host.md).\n- `packages/cadgen` is the whole distribution, not just the Python: artifact\n  generation, the CLI parsers behind every skill command (`cadgen/cli`), the warm\n  build daemon (`cadgen/daemon`), and\n  the JS/SPA assets it executes (`cadgen/_runtime`, built by\n  `scripts/bundle/cadgen-runtime.sh`). Skills consume it as an\n  installed distribution.\n- Create lightweight shared Python packages under `packages/` when a helper\n  should not inherit heavier package dependencies.\n- Use path-targeted search, validation, and `git status`; avoid broad scans over\n  generated CAD artifacts unless the task requires them.\n- Treat `VERSION` as the canonical release version. Do not hand-edit duplicate\n  package, plugin, lockfile, or Python `pyproject.toml` versions;\n  `scripts/release/bump-version.sh` and `scripts/bundle/bundle.sh` stamp them\n  from the canonical version.\n\n## Environments\n\n- Prefer `./.venv/bin/python` for CAD Python work.\n- Keep new branch checkouts and git worktrees lightweight by default. Do not\n  copy `.venv/` or `models/` through `.worktreeinclude`; recreate `.venv/`\n  inside the worktree only when Python dependencies are needed for the workflow.\n- In Codex or Claude Code worktrees, prefer the skill instructions and scripts\n  under the current worktree's `skills/` directory over globally installed\n  skills or plugins from another checkout.\n- Test skills and the plugin in an agent app with\n  `scripts/install/dev_install.py <host>` (`CONTRIBUTING.md`, \"Test In Agent\n  Apps\"). One copy of the plugin per app: never the development install beside\n  the published plugin, or beside the skills installed loose.\n- Install dependencies only for the workflow being changed.\n- Do not commit `.venv/`, `node_modules/`, caches, `tmp/`, local credentials, or\n  printer config.\n\n## Checks\n\nRun the smallest path-targeted check that covers the change. Use broad wrappers\nwhen touching shared surfaces or before handoff:\n\n- Code tests: `scripts/test/test.sh` (JS, API, Python, then policy).\n- Focused runners: `scripts/test/test-js.sh`, `scripts/test/test-api.sh`, `scripts/test/test-docs.sh`,\n  `scripts/test/test-python.sh`, `scripts/test/test-global.sh`.\n  `test-python.sh` takes `--select cadgen|viewer|skills|all` and\n  `--print-weights`; `test-js.sh` takes `--select core|ui|web|mcp|all`. See\n  `scripts/README.md`.\n- In GitHub Actions, `test.yml` runs one job per concern, each only when the\n  change reaches it: `scripts/github-workflows/select_checks.py` maps every\n  changed path to the tests that read it, and the Python jobs run just those\n  files. A test that reads a new kind of path gets a rule there\n  (`CONTRIBUTING.md#ci`); an unknown path, `VERSION`, `packages/core`, the\n  lockfiles and the test machinery run everything, as does a manual dispatch.\n  Required check names stay stable.\n- Canonical release version: `scripts/release/check-version.sh`\n- Packaged runtime builds and is complete: `scripts/bundle/bundle.sh --check`\n- CAD Viewer or shared packages: build exports with `npm run build:packages`,\n  then `npm --prefix packages/core test`, `npm --prefix packages/ui test`,\n  `npm --prefix apps/web run test`, `npm --prefix apps/web run build`.\n  The Viewer is two languages and `npm run test` covers only the client — the\n  backend's suite is `tests/python/packages/cadgen/viewer`, run by\n  `scripts/test/test-python.sh`. Touching `cadgen/viewer/` means running that.\n- Docs site: `npm --prefix apps/docs run check`\n- API (`api.texttocad.dev`): `npm --prefix apps/api test`\n- Targeted Python tests: `./.venv/bin/python -m unittest <changed test paths>`\n\nWhen a task changes what the bundlers consume, run `scripts/bundle/bundle.sh`\nand confirm the change lands in the built runtime. There is nothing to commit:\n`_runtime/` is gitignored end to end, so what a reviewer reads is the source and\nwhat a user gets is the wheel the release builds from it.\n\n## CAD Viewer\n\nThe app-facing playbook lives in `apps/web/README.md`: launcher contract\n(reuse, ports, `--new`), dev vs prod, and the catalog/link-verification\ngotchas. The repo-side half — the lightweight-worktree recipe and\nroot workspace dependencies — lives in `CONTRIBUTING.md` under \"Viewer Development\nIn This Repo\". Read them before starting, stopping, or debugging a Viewer.\nNever stop an instance you did not start; packaged-runtime checks go\nthrough `scripts/bundle/bundle.sh`.\n\n## Git\n\nNo Git LFS, and every file under 5 MiB: the repository root is the plugin, and\nclaude.ai's plugin directory refuses files a filter rewrites at checkout, so\nheavyweight media stays out of the tree. Local hooks live in `.githooks` and\ndelegate build checks through `scripts/git-hooks/pre-commit`.\n","apps/web/skills/smui/SKILL.md":"# Shared UI styling for the CAD Viewer\n\nThe CAD Viewer is a Vite and Tailwind v4 host of `@text-to-cad/ui`. Shared\ncomponents and CAD controls live in that package; the web app owns its top bar\nand browser-specific composition. In a repository checkout, read\n`packages/ui/README.md` before changing a\nshared control.\n\nThe package migration is a pure refactor. Preserve the current appearance and\ninteraction behavior of each app: typography, spacing, borders, surfaces,\nresponsive layout, loading states and saved preferences. Moving a component\ndoes not authorize a redesign. Compare changes against the existing app in\nboth color schemes and at narrow widths.\n\n## Tokens and ownership\n\n- Canonical tokens live in\n  `packages/ui/src/styles/tokens.css`.\n  The current palette is neutral, with light and dark variants and the existing\n  radius scale. Reuse those values and semantic utilities such as\n  `bg-background`, `text-muted-foreground`, `border-border` and\n  `text-destructive`.\n- The web entry stylesheet,\n  [globals.css](../../src/client/styles/globals.css), imports\n  `@text-to-cad/ui/tokens.css` and `@text-to-cad/ui/styles.css`. Shared utility classes\n  and assets are built by the UI package; apps do not scan another package's\n  source or duplicate its token definitions.\n- The host supplies the color scheme and applies its existing `.dark` behavior.\n  Keep the CAD scene theme separate from host appearance, using the existing\n  preference adapter and shared renderer contract.\n- Preserve the current font, text case, radius, opacity and shadows of the\n  affected component. There is no additional Nord palette, glass-surface rule,\n  uppercase-label rule or monospace requirement to apply.\n- Import reusable controls through `@text-to-cad/ui/primitives/*` and `cn` through\n  `@text-to-cad/ui/utils`. The class-merging helper lives in\n  `packages/ui/src/lib/utils.js`.\n  Keep existing host-specific controls when their appearance or behavior differs.\n\n## Controls and interaction\n\nUse the existing shared primitives and variants. Preserve their keyboard\nnavigation, focus styling, labels, disabled states and pointer behavior when\nmoving or wrapping them. Icon-only controls need an accessible name; use the\nexisting Lucide icons and tooltips. Status and error text must convey meaning\nwithout relying only on color.\n\nFor rows in a CAD file's panels (its own panel and Display), follow\n`packages/ui/docs/settings-ui.md`. It defines the\nexisting row types, control widths, units, section structure and accessible\nlabel conventions. Reuse those CAD controls inside the renderer instead of\nadding a second settings layout.\n\nKeep loading, empty and error states in their existing positions. Preserve\nfocus and dismissal behavior for menus and dialogs, and retain reduced-motion\nhandling for animated indicators. A visual cleanup must not remove feedback,\nchange a control's availability or alter selection and navigation behavior.\n\n## Validation\n\nCheck the affected app and shared component in light and dark modes, at the\nnormal and narrow layouts, and with keyboard as well as pointer input. For CAD\nchanges, include the file's panels, toolbar, loading/error and preview\nstates; the `packages/ui/docs/render-types.md` cover\ncross-format and theme behavior. Rebuild shared package output before checking\nan app that consumes it, following the package README. Do not use a migration\nor documentation update to change the styling baseline.\n","packages/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2FREADME.md -->","packages/cadgen/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcadgen%2FREADME.md -->","packages/core/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcore%2FREADME.md -->","packages/core/docs/render-pipeline.md":"# Render Pipeline\n\n`@text-to-cad/core` exposes a staged render pipeline for shared viewer, docs, and generated\nsnapshot browser-runtime work. A STEP document goes:\n\n```js\nconst source = await loadSource(input, sourceOptions);\nconst model = buildModel(THREE, source, modelOptions);\nconst viewport = renderModel(THREE, model, viewportOptions);\nconst result = await captureModel(viewport, captureOptions);\n```\n\nEvery other file family (GLB, STL, 3MF, URDF, SRDF, SDF) is drawn by ITS OWN scene\nbuilder, the one the viewer's renderer for it calls too: the snapshot page loads the\nfile with that family's loader, builds the scene with that family's builder, dresses it\nin the look the viewer's viewport would, and hands it to the same `renderModel` and\n`captureModel` (`common/headlessScene.js`). No family is flattened into mesh data for a\nrender.\n\nThe stages keep ownership narrow:\n\n- `loadSource` owns STEP source and sidecar loading plus file-kind validation.\n- `buildModel` owns the CAD object graph, records, selection, clipping,\n  materials, topology/display edges, and STEP parameter effects.\n- A family builder owns its scene: `createGlbScene` (`lib/render/glbScene.js`),\n  `buildMeshScene` (`lib/render/meshScene.js`), `createRobotScene`\n  (`lib/urdf/robotScene.js`). Each returns the scene contract of\n  `lib/viewer/sceneContract.js`, and each scene gives a sibling (`createSibling()`): a\n  second scene of the same file with its own objects, materials and look over the first's\n  geometry (`lib/render/sharedGeometry.js`), which the viewer's Preview draws beside\n  its editor. A GLB's sibling is a SkeletonUtils clone wearing copies of the file's authored\n  materials, kept before any look, with a sibling `document` for its own routine; a robot's\n  has its own links and pose, and its own vertex colours. Ownership is\n  [resource-ownership §1](resource-ownership.md#1-shared-ownership-and-disposal).\n- `renderModel` owns renderer, scene, camera, lighting, background, floor,\n  framing, resizing, and render loop concerns.\n- `captureModel` owns deterministic snapshot outputs without filesystem writes.\n\nThe CAD skill's Python snapshot CLI remains responsible for job parsing, path\nresolution, Playwright routing, and writing returned outputs to disk.\n\n## Modules\n\n### `common/viewSettings.js` and `common/sceneSettings.js`\n\n`normalizeViewSettings(display)` is the strict public schema shared with the\nsnapshot CLI. It preserves omitted groups and inherited appearance, defaulting\nonly `mode` to `solid`. A file's modes, in order, are `solid`, `shaded`, `parts`,\n`xray`, `hidden-line`, and `wireframe` (`VIEW_MODE_VALUES`); `grid` is a boolean\nswitch every mode draws, off unless said. Appearance, camera, clip and exploded are\nshared. A mode is a look at its own defaults: it has no settings of its own, and\nanything else (a per-mode tree, a style group) fails validation.\n\n```js\n{ mode: \"parts\", grid: true, camera: { projection: \"orthographic\", focalLength: 50 }, clip: { axis: \"z\", at: 4 } }\n```\n\nPreview's photographic look (Render) is no mode: it is asked for on its own, by the\nviewer's Preview and a snapshot's `preview`, over the same display. Its own settings\n(`PreviewSettings`: `parts`, `lighting`, `background`, `floor`) are the viewer's,\nkept beside the display, never in it (`normalizePreviewSettings`,\n`resolvePreviewSettings`, `previewSettingsChanged`); a snapshot draws Preview at its\ndefaults. `readViewSettings` and `readPreviewSettings` read a stored record setting\nby setting, leaving out what this build cannot read, without rewriting it.\n\n`resolveViewSettings` returns the established renderer groups. Solid is shaded with\nthe model's visible CAD edges where the view offers them (`edges`); Shaded is the same\nsurfaces without; Parts is Solid with the by-part palette. X-ray uses 0.22 surface\nopacity and all edges; Hidden line uses depth-only surfaces and visible edges; Wireframe\nhas no fill and all edges, both lighter on a dark backdrop. With `preview: true` it\nresolves Render whatever the mode: perspective, no edges, no grid and the photographic\nstudio, from `previewSettings`. The modes use the shared camera, orthographic by default.\nThe grid is the same measuring grid in every mode (`viewGridDefaults`). The view's\ncapabilities are explicit injected lists (`viewFeaturesForKind`): a STEP every mode, any\nother 3D file Solid, and Parts where it has several parts (`scenePartCount`); a saved mode\na view does not offer resolves as Solid without rewriting storage. Surface opacity\nmultiplies authored opacity; colours remain presentation settings, never model material\nedits.\n\nClip keeps coordinates below its plane by default (`invert: false`); Reverse (in Clip's axis dropdown)\nkeeps coordinates above it. `clip.at` is the plane's coordinate on its axis in millimetres;\nomitting it cuts through the middle of the authored bounds. A disabled clip cuts nothing.\n`clip.partColors` defaults to true. Caps use each part's normal base material color,\nwithout selection/hover tints or opacity; false uses uniform amber (`0xe9a451`).\nCaps stay opaque even for transparent parts, and outer materials are unchanged.\nEach intersected part has a winding pair and an opaque fill, so holes remain empty;\nthe fill (as wide as the model on the plane) writes the stencil back to 0 wherever it\ncovers, so no part needs a stencil clear of the whole buffer after it. Cap geometry/materials are owned by Clip; stencil\ngeometry is borrowed from the display meshes. Generic scene looks preserve\n`mesh.userData.cadBaseColor` before highlight ink; STEP records use `baseColor`\nand normal effect colors. A mesh with several materials uses its first material\nas its cap color; texture and vertex-color variation are not sampled.\n\n`resolveViewSceneSettings({display, preview, previewSettings, camera, appearance, quality})` is the public\nViewer/snapshot scene-policy boundary (`preview`: Render over the display, as above). Its `view` is the full grouped state;\n`display` is the historical internal draw policy, including `surfaces` and\nedge color/visibility. `camera` combines the top-level pose with the grouped\nlens. Public top-level and per-output cameras contain pose/framing only;\nprojection and focal length belong to `display.camera`. Focal length ranges\nfrom 20 to 200 mm. `orthographicHalfHeight` is the positive pre-zoom vertical\nhalf-extent and may be retained while perspective is active.\n\nThe resolver's `render.enabled` means lighting, background or floor needs the\nstudio scene helper. `render.configuration.lighting.enabled` independently\ncontrols photographic illumination and PMREM allocation. `theme` retains the\nneutral workbench recipe whenever photographic lighting is disabled, including\nwhen a custom background or floor is present. Consumers build CAD models with\n`surfaceSettings: resolved.view.surfaces` and the resolved edges; they must not\nreapply historical mode-owned edge forcing. `resolveSceneSettings()` remains an\ninternal compatibility resolver for older low-level consumers.\n\nBackground opacity follows the ordinary alpha convention: 0 is transparent and\n1 is opaque. Fractional alpha is carried by the renderer clear color with\n`scene.background = null`, so viewer checkerboards and PNG captures agree. Over that\nclear the studio draws its backdrop (`studioBackdrop.js`), first of everything and\nwriting no depth: the background color as a seamless sweep, lifted toward white\nbehind the middle of the frame, shaded toward the corners and, by how far the view\nray looks up, above the horizon (`PHOTOGRAPHIC_STUDIO_BACKDROP`; the shade is a larger\nshare of a dark color than of a light one). It writes the clear's alpha, premultiplied\nas three's clear is, so a half-transparent background keeps its gradient; at alpha 0 it\nis not drawn, and a caller that renders with `scene.background` taken away and a clear\nalpha of 0 (a thumbnail) gets nothing of it. The floor's mirrored draw leaves it out.\nThe floor is independent of background alpha. Render's stands at authored\nZ=0 (`placement: \"origin\"`, `RENDER_FLOOR_PLACEMENT`, the Render recipe's backdrop\ndefault too); `placement: \"lowest\"` follows the current model minimum without moving\ngeometry or lighting. Its double-sided plane uses one transparency pass,\nkeeping geometry below the origin visible. Depth fitting includes the actual floor elevation;\northographic views can use a signed near plane when the floor crosses the eye.\n\nLighting quality is `preview` or `final`, mapping to standard/high scene policy.\nThe viewer passes `lightingQuality: \"preview\"`; the CLI baseline is `final`.\nThat environment default also feeds Preview's default comparison and does not pin a saved\noverride. An explicit Preview `lighting.quality` wins in the viewer; a snapshot's `preview`\nis always `final`.\nFinal uses the bounded L3 mesh rung, a 0.25px viewport target, 4096px spotlight\nshadows, a 512px procedural environment and 2x snapshot capture, and a still view\nin the viewer converges over 64 samples of the studio's frame (Preview: 32). Technical\n`quality.tessellation` and `output.renderScale` remain explicit overrides.\nPNG output retains requested dimensions by resampling the full drawing buffer.\n\nThe two studios use one physical Render pipeline. A neutral HDR key card, a rear\nfill card, a side bounce card and the studio's fixed soft sources\n(`PHOTOGRAPHIC_STUDIO_PANELS`: a large overhead softbox and two tall strip boxes\nabout 75 degrees either side of the default camera) hang in a studio sweep and\ngenerate a procedural PMREM for authored PBR reflections; one aligned,\nmodel-scaled SpotLight supplies direct illumination and PCF shadows. Softbox\nsize changes every source's area and bounded shadow softness while keeping each\nsource's total flux stable. Fill sets both fill cards' radiance as a fraction of\nthe key card's. Rotation moves the direct light and `scene.environmentRotation`\ntogether around CAD Z. An overhead side key reveals depth; the rear fill keeps\nhorizontal reflections readable and the bounce, low on the key's far side,\nlifts the faces the key cannot reach (an iso view's right-hand side). The\nsoftbox and strips are brightest along their centres and fall off toward their\nedges, and the sweep (`PHOTOGRAPHIC_STUDIO_ROOM_RADIANCE`: 0.14 overhead, 0.07\nat the horizon and a light floor's 0.3 below, easing between them) is dimmer\nthan they are, so polished and satin metal show defined gradients and highlight\nlines between darker gaps — what reads as metal rather than grey plastic — and\ndark glossy parts keep their color. Nothing is a void: a face turned away from\nevery source keeps a soft fill. Environment radiance and direct illumination\nshare a calibrated zero-EV lighting budget. Khronos PBR Neutral tone mapping is\nfixed; `toneMappingExposure` is `2 ** exposure`. Light and dark differ only in\ndefault backdrop color.\n\n#### The studio's frame\n\nRender's lighting is drawn through the studio's frame (`studioFrame.js`), which\n`applyPhotographicStudio` keeps as `state.frame` while its lighting is on, and the viewer\nand the snapshot both draw the scene through. Three draws the scene as before, but into an\nXR-flagged target of the frame's (`isXRRenderTarget`), which three draws exactly as the\ncanvas, tone mapping and colour space included, so no material compiles a second program;\nits depth is kept. Small full-screen passes then add:\n\n- **Ambient occlusion.** Ground-truth AO (GTAO, after XeGTAO) from that depth alone, at half\n  the drawing buffer's resolution: the horizon either side of each pixel along three screen\n  directions, four steps each, within a radius of 0.1 of the model's rest radius (never more\n  than a fifth of the view's height). Every sample is read at a full-resolution pixel's\n  centre; a depth read from one pixel and placed at another turned sloped faces into\n  staircases of false horizons. A depth-aware blur smooths it, and the composite upsamples it\n  by depth, so it never bleeds across a silhouette, and darkens the scene's colour by it in\n  linear light (`power` 2). The floor writes no depth, so it keeps its own contact shadow;\n  the background is never darkened. This is what reads as a photograph rather than a\n  render: the seam between a cover and its housing, the space between stacked plates, a\n  bolt head on its plate, the inside of a bore.\n- **Convergence.** A still view is the average of `samples` samples, each blended into a\n  half-float accumulation at `1/(n+1)` and presented with a level of dither. Every sample but\n  the first moves the camera a fraction of a pixel (Halton 2, 3), so edges and fine\n  highlights are anti-aliased; turns the occlusion's directions (interleaved gradient noise\n  per sample), so it converges smooth; and moves the key light to a point of its softbox's\n  face (`sampleKey`: R2 over the key card's face, `PHOTOGRAPHIC_STUDIO_SOFT_SHADOW.spread`\n  of it, scaled by Size), so the key's shadows become a softbox's: crisp where a part meets\n  what it shades and soft away from it, on the model and, through the floor shadow's\n  composite, on the floor. A shadow pass that only moved the key holds the floor shadow's\n  heights (`holdHeights`) and re-composites its key shadow alone.\n\nThe frame's caller says whether a frame is `still`: nothing but, perhaps, the camera changed\nsince the last. A still frame is the next sample unless the camera has moved the model by\nmore than `STUDIO_FRAME_DRIFT_PIXELS` (0.25) on screen since the picture's first sample, so the\ntail of a damped orbit still converges; anything else (a camera that moved, a pose, a\nroutine's frame, a setting, a resize) starts again from the first sample, which is a whole\npicture of its own: the key at rest, the occlusion denoised in space. `render` answers whether\nsamples are still owed (`refining`); the caller asks for the next frame while they are, and\nfor none once the picture has converged. A moving view therefore pays for one draw and the\nsmall passes a frame; the samples only while it rests.\n\nMeasured at 3024 × 1964 on an M1 Max over 0.6 M and 3 M triangles: a moving frame\n3.1 → 4.5 ms and 5.0 → 6.6 ms, a sample 5.2 and 7.8 ms, all 32 samples back to back 155\nand 240 ms (in the viewer, a sample a display frame: about half a second). Memory is the scene target (8-bit colour and depth-stencil, multisampled as\nthe canvas is: four samples, two on a canvas of pixel ratio 2 or more), its resolve, a\nhalf-float accumulation and two half-resolution occlusion targets: about 200 MB at that\nsize, released with the studio. Software rendering has no frame: three's single draw, as\nbefore, whose key casts no shadow to soften.\n\n`applyPhotographicStudio(THREE, runtime, configuration, options)` owns the\nsynchronous light, ground and renderer state and updates those objects in place.\n`disposePhotographicStudio(runtime)` releases only those objects, the studio's frame\nincluded (`applyPhotographicStudio`'s `frame`: false for none, or its `samples` and\n`multisample`). The caller\nseparately owns the PMREM returned by\n`createEnvironmentResource(renderer, configuration, {size})` — synchronous GPU\nwork, returned directly rather than as a promise — assigns its\ntexture to `scene.environment`, and releases it through\n`disposeEnvironmentResource()`. `environmentResourceIdentity()` includes\nsoftbox size, fill, and PMREM resolution; it excludes rotation so rotating the\nrig is a live scene update rather than an environment rebuild. A resource also\nexposes `readPixels()` for transferring its half-float PMREM from worker WebGL.\nThe interactive UI prepares and caches these maps off the main thread; snapshot\nrendering can keep the synchronous caller-owned path.\n\nThe floor has no edge (`PHOTOGRAPHIC_STUDIO_FLOOR_FADE`): from 1.6 times the model's\nreach (the larger of its rest radius and its footprint's half-diagonal) it fades out\nto its inscribed circle at 6 times it, where the two-triangle plane ends, so a low or\nwide view shows no horizon line and no corner, only the backdrop. The fade is applied\nto the floor's alpha after its reflection is laid over it, so the reflection fades with\nit; it is squared, so its tail is long even foreshortened at a grazing angle. Camera\nfitting keeps `PHOTOGRAPHIC_STUDIO_STAGE_RADIUS_MULTIPLIER` as far-plane padding, far past\nthat rim. Near-plane fitting includes the visible floor as well as the model.\n\nThe physical floor is mostly self-lit backdrop color and receives no shadow\nmap; its shadow is a contact layer drawn over it (`studioContactShadow.js`):\ndarkest where the model meets the floor, a broad occlusion fading with height,\nand the key's cast shadow crisp near the model and softening into a wide, light\npenumbra away from it. The layer is one texture over a square fitted to the\nrest placement (`PHOTOGRAPHIC_STUDIO_CONTACT_SHADOW`), as deep as the floor is\nopaque, and hidden while the studio's lighting is off; a floor at zero opacity\nis not drawn, so it bakes nothing. It is baked in two steps: the heights, a\ndepth pass of the shadow casters seen up through the floor by a probe light\nthat is never added to the scene, and the composite, three small full-screen\npasses over those heights and the key's own shadow map. Both are due only when\nthe key's shadows re-render: a sentinel in the studio, a shadow caster of zero\narea that every shadow pass and the main pass draw (it rasterizes nothing in\neither), notices that pass. The composite runs in that frame, so the key's cast\nshadow on the floor follows every frame a model moves in. The heights do too,\nunless the caller passes a `heightInterval` (`applyPhotographicStudio`'s\n`contactShadow` option): then a scene that keeps changing re-measures them at\nmost that often, and once more when it stops, in a frame the layer asks for\n(`requestFrame`), so what is shown at rest is exact and only the contact\ndarkening lags while a model moves. The interactive viewer re-renders shadows\nonly for a change that can alter them, never for a camera move or a highlight,\nso it bakes nothing on those frames; the snapshot renderer, which renders\nshadows every frame and passes no interval, bakes both steps every frame. A\nruntime that renders in software (`softwareRendering`) gets no contact layer:\nits key casts no shadow either. A transparent legacy backdrop's floor stays a\n`ShadowMaterial` catcher of the key's shadow.\n\nThe floor and its shadow layer are dithered: each adds up to one 8-bit level of\nnoise as it is drawn (the floor's divided by its alpha, so its blend keeps it\nwhole; the layer's drawn premultiplied, so the blend adds exactly that noise over\nwhatever floor is under it). A dark floor's shading spans only a few levels of\nthe canvas, and rounding drew it as wavy contour bands; the noise is too fine to\nsee and leaves every average colour where it was. A snapshot drawn at a render\nscale above 1 averages that scale squared of drawn pixels into each one it keeps,\nwhich quietened the noise to a fraction of a level and let the rounding band\nagain: it passes its render scale as `applyPhotographicStudio`'s `ditherScale`,\nwhich spreads both noises that much wider (`syncDitherScale`). The viewer keeps\n1, and at 1 nothing is defined: its shaders are the ones they always were.\n\nThe floor's finish is `backdrop.groundFinish` (`PHOTOGRAPHIC_STUDIO_FLOOR_FINISHES`):\n`matte`, the default, or `glossy`, a glossier surface that also reflects the model\n(`studioFloorReflection.js`). Before each frame of the scene (its `onBeforeRender`)\nthe scene is drawn again from the camera mirrored in the floor, at half the\ncanvas's resolution; three small passes fade and soften it with each reflected\npoint's height (crisp where the model meets the floor), none of them sampling the\ntarget it draws into, a draw WebGL refuses, and the floor lays it over\nitself with a Fresnel weight, in display colour, so a light floor shows it as a\ndark one does. The mirrored draw runs when the camera moved or the frame re-renders\nshadows (it renders them, and the frame keeps them); a frame for a highlight keeps\nthe last reflection and catches up within 400 ms; a snapshot draws it every frame.\nIt runs before the frame, at the frame's own render depth and into a target three\ntreats as the canvas (`isXRRenderTarget`): drawn nested in the frame, or as an\nordinary target, it made every lit material rebuild its program key twice a frame,\nwhich cost more than the draw. A matte floor allocates none of it; turning the\nfloor matte, the studio's lighting off or the floor off releases it.\n\nEnvironment radiance and direct illumination are calibrated together at zero EV\nacross colored assemblies, gray mechanical models, and authored metal/plastic\nfinishes.\n\n#### What Render's public contract does NOT have\n\nNo global material, no color grading, no arbitrary lights, no floor physics, no\nglow controls. Adding one of these is a change to the law in the package\nREADME, not a new option here.\n\nMaterials reaching that scene stay authored. STEP package material channels are\ninputs to the rig: assigned sparse materials use roughness 0.42, metalness 0.03,\nclearcoat 0, clearcoat roughness 0.26, and opacity 1; an absent base color\nretains the STEP color, and authored opacity multiplies its source alpha.\nA GLB, a mesh file and a robot reach the studio as the scene their viewer renderer\nbuilds. A GLB is its native glTF hierarchy, so its textures and PBR channels stay\nattached and Render wears them exactly as authored; an STL, a 3MF and a robot author no\nfinish, so they wear the studio's surface over their colours (a 3MF's object colours, a\nrobot description's colour, else its link mesh's own).\n\nSnapshots use the same grouped display contract. Photographic lighting supports\nonly the `view` capture mode. Camera,\nselection, clipping, exploded view, guides, part colour, animation, video,\nkinematics, robot joints, tessellation quality, per-output cameras and output\nsizing remain composable.\n\nPhotographic lighting creates its WebGL renderer with\n`logarithmicDepthBuffer: false`. Three's logarithmic depth shader path does not\nproduce usable contact shadows. Render callers fit ordinary-depth near/far\nplanes to current model bounds with `fitCameraDepthToBounds(camera, bounds)`.\nThe interactive viewer uses this fitted conventional depth for every preset so\nsettings can enable shadows without replacing its renderer/canvas. The fit also\nuses visible records for closeups, the floor's actual elevation, and the grid's\nplane and bounds independently of Floor. Otherwise the subject's near plane\nclips foreground guides, or the far plane truncates their finite span. A\nperspective camera passes its `pivot` (the point it looks at): its near plane\nnever comes nearer than 1/256 of the pivot's depth, so a closeup the fit cannot\nmeasure (the camera inside a part's own box, or a routine deforming what it\nmeasures) keeps its depth resolution instead of making close surfaces fight.\nSnapshots draw every preset the same way: ordinary depth, fitted to each output\nwith the same inputs (its placed records, the studio floor's elevation, the\ndrawn grid's span and the camera's target as pivot). A logarithmic buffer would\nalso lose the instanced CAD edges, which write no logarithmic depth: under a\nperspective camera every edge failed its depth test against the surfaces.\n\n### `common/source.js`\n\n```js\nimport {\n  loadSource,\n  stepParameterRuntime\n} from \"@text-to-cad/core/common/source.js\";\n```\n\n`loadSource(input, options)` returns a normalized STEP render source:\n\n```js\n{\n  kind,\n  meshData,\n  selectorRuntime,\n  displayEdgeRuntime,\n  stepParameterSource,\n  resolved,\n  url,\n  glbUrl,\n  cadPath\n}\n```\n\nAccepted input fields:\n\n- `kind`: `step` or `stp`, or inferred from a URL. A GLB, STL, 3MF, URDF, SRDF\n  or SDF is refused by name: it is drawn by its own scene builder\n  (`common/headlessScene.js`), never flattened into mesh data here.\n- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.\n- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.\n- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.\n- A caller that passes a `resolved` packet together with any source URL must also pass\n  `resolved.inputPath`. Render asset caches are page-lifetime, so a resolved job has to name the\n  source its cache entries belong to; a resolved job without `inputPath` is rejected rather than\n  cached under an unidentified source. Callers with no `resolved` packet (the interactive viewer and\n  the docs hero renderer) render one source per page and need nothing.\n- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller\n  already owns sidecar loading.\n- `kinematics`: pose values for the model's kinematics — a declared preset name,\n  or `{dof: value}`. Same spelling as the `--kinematics` flag, the snapshot job\n  key and the sidecar section.\n- `stepParameterUrl` or `resolved.stepParameterUrl`: model sidecar\n  (`.step.json`) URL, whose `kinematics` section is compiled here.\n- `quality.tessellation`: explicit STEP tolerances in every mode. When\n  omitted, `preview: true` selects the bounded final mesh rung.\n\nSTEP-only options are rejected for non-STEP sources. The old shared `params`\nfield is rejected, and so is the retired `stepParameters` spelling; use\n`kinematics`.\n\nUse `stepParameterRuntime(stepParameterSource)` to turn the loaded parameter\nsource into the runtime object `buildModel` accepts.\n\n### `common/cadScene.js`\n\n```js\nimport {\n  buildModel,\n  fitCameraToModel\n} from \"@text-to-cad/core/common/cadScene.js\";\n```\n\n`buildModel(THREE, source, settings)` returns a model API:\n\n```js\n{\n  source,\n  meshData,\n  root,\n  modelGroup,\n  edgesGroup,\n  displayRecords,\n  records,\n  bounds,\n  restBounds,\n  radius,\n  runtime,\n  update(nextSettings),\n  dispose()\n}\n```\n\n`source` can be a `loadSource()` result or raw mesh data. The model owns the\nThree.js object graph and its mutable state.\n\n`bounds` follows the live pose — what lighting, the floor, shadows and clipping\nneed. `restBounds` is the same model at its ZERO pose, before a parameter, mate\nor animation frame moved a record, and it is what a camera fit is grounded on so\nthat posing a model never re-frames it. A package that declares its whole box\n(assembly.json's `bbox`, `declaredBounds` in its composition) rests in that box,\nas the scene contract has it: the viewer frames and grounds it, and a snapshot\nsizes its ground and radiates an exploded view from it. A family scene carries\nits own (`restBounds` in the scene contract): a robot's is every joint at its\ndefault.\n\nCommon settings:\n\n- `theme`: normalized or raw CAD scene settings. Render passes none and supplies\n  `materialSettings` instead.\n- `displayMode`: `shaded`, `shaded_edges`, `transparent`, `hidden_edges`,\n  `hidden_lines_removed`, `unshaded`, or `wireframe`.\n- `edgeSettings`: display-owned CAD edge style. Themes do not own edges.\n- `materialOverrides`: sparse explicit PBR overrides; authored PBR otherwise\n  wins over studio material fallbacks.\n- `scale`/`sceneScale`: CAD or robot scene scale.\n- `selection`: internal selection/filtering state. `focus` and `hide`\n  filter rendered parts before records are built. Viewer-only fields such as\n  `selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.\n- `clip`: normalized clip-plane settings.\n- `stepParameters`: compiled kinematics runtime object, from\n  `stepParameterRuntime()`.\n- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.\n- `renderPartsIndividually`: build per-part records instead of a whole mesh.\n- `edgeRendering`: declarative edge rendering configuration.\n\nDeclarative screen-space edge rendering:\n\n```js\nbuildModel(THREE, source, {\n  edgeRendering: {\n    mode: \"screen-space\",\n    Line2,\n    LineGeometry,\n    LineSegments2,\n    LineSegmentsGeometry,\n    LineMaterial,\n    wireframeEdgeColor: \"#111827\"\n  }\n});\n```\n\nThe model keeps screen-space line material bookkeeping internal through\n`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.\nCallers should not provide callbacks that create edge objects.\n\n`model.update(nextSettings)` merges mutable settings, rebuilds geometry only\nwhen needed, reapplies material/selection/clip/STEP parameter state, and returns\nthe same model API. `model.dispose()` releases model-owned scene objects and\nSTEP parameter cleanup hooks.\n\nSource colors: a GLB's material base colors and its `COLOR_0` vertex attribute\nboth count as source colors (`lib/render/glbMeshData.js`). A vertex-colored\npart renders on a white base so the ramp shows unmixed — an FEA result or scan\nheatmap keeps its colors even when the file declares no materials at all — and\n`overrideSourceColors` in material settings replaces both kinds with display\nfills.\nPackage components carry no vertex colors; their coloring is the descriptor's\noccurrence/component/face colors.\n\n`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic\ncamera framing helper used by interactive rendering.\n\n### Display modes, CAD edges and geometry sharing\n\nInternal draw modes are `shaded`, `shaded_edges`, `transparent`,\n`hidden_edges`, `hidden_lines_removed`, `unshaded`, and `wireframe`. Public\npresets resolve to those implementation details plus independent surface and\nedge settings; never feed a public preset into `normalizeDisplayMode()`. Normal CAD\nkeeps authored albedo, opacity and finish. Workbench PBR channels are fallbacks;\na small neutral reflection environment makes authored metals readable without\nloading the photographic studio.\n`resolveDisplayMaterialSettings()` applies the shared Original, Single color\nand Color by part policy without app state.\n\n**Scene geometry is the tessellator's INDEXED output.** A surf component's\n`meshData` shares the tessellation's vertex, normal and index buffers by\nreference (a decoded `.tess` cache entry is copied out of its one entry\nbuffer) and is never expanded per triangle corner.\n\n**CAD edges are not a surface shader.** `surfMeshData.js` emits indexed line\nsegments (`cadEdgePositions` + `cadEdgeIndices` + `cadEdgeClassRanges`, from\nthe same tessellation's boundary polylines, ~1.5 bytes per surface triangle)\nand `cadScene.js` draws them as ONE instanced screen-space line draw per\ncomponent (`cadEdgeInstances.js`): the instances are every (segment,\noccurrence) pair, decoded in the vertex shader from a per-component segment\ntexture (32 B per drawn segment, cached on the component) and a per-set\ninstance texture (128 B per occurrence: matrix, colour, opacity, visibility,\nhighlight).\n\n**Every mode draws the same edges, Wireframe included.** Wireframe is the CAD\nedges through the model with no fill, never the tessellation's triangles: a\nswitch to it reuses the segment textures (or, for a baked mesh without segments,\nthe one cached edge geometry per component, keyed by no mode) that Solid, X-ray\nand Hidden line draw, so it builds nothing per triangle. It once built three's\n`WireframeGeometry` over every triangle on the main thread: 10 s and +1 GB on a\n437 MB STEP, for a picture that read as a solid blob.\n\n`cadInk.js` fixes one dark model-edge palette and nominal widths per class:\nfeature 1, tangent 0.65, seam 0.8, and degenerate 0 (hidden). Public\n`display.edges` exposes enabled, visible/all visibility and color; widths stay\nrenderer-owned and the default color preserves the restrained class palette. Grid settings keep\n`enabled` plus optional `color`, `opacity` and `density`; omission follows the\nappearance's graph-paper ink (`GRID_LINE_DEFAULTS` in `stageGrid.js`), while an\nexplicit color applies to every minor and major line but never the red X and\ngreen Y axes. Viewer and snapshots draw the same grid (`createGridMesh`): its\nstep, majors and long distance fade are in `packages/ui/docs/render-mode.md`.\nThe Axes line is the blue Z axis.\nAppearance updates preserve model lighting, materials, geometry,\nsegment textures and occurrence slots; only the canvas and guides adapt.\nAn edge color edit keeps them too: `model.update` recolors the live edge draws\nin place.\n\nA thickness is a FULL width in DEVICE pixels — every line shader normalises its\nextrusion by the drawing buffer, never the CSS size, and the fragment stage\nfilters a box with a symmetric ±0.75 px kernel. Integrated coverage equals the\nnominal width even for subpixel lines, and zero width has zero coverage. Both\nline paths share the filter; antialiasing does not inflate thin lines or depend\non the framebuffer sample count.\n\nPer-occurrence highlight, dim, hide, focus, exploded placement and selection\nare slots in that texture, written by the same record passes\n(`applyDisplayRecordTransform`, `applyPartVisualState`,\n`syncRecordEdgeMaterials`) that drive a plain line object; highlighted\noccurrences draw in a second pass at the highlight render order. A deformed\ntube leaves its slot for private screen-space lines, one per drawn class, that\nbend with the surface and preserve the same class weights and colours.\nBasic-only hosts use separate per-class materials too, so appearance changes\nretain private edge geometry and cannot recolour another scene's component.\nGPU cost per component: two textures, one 4-vertex quad, two materials, one\ndraw call (+1 while any occurrence is highlighted).\n\n**One upload per component.** Geometry built from a shared component is cached\non the component object (`part.sourceMesh`), never on the composed package\n`meshData`: a package is re-composed on every progressive publish and LOD swap,\nand every occurrence, publish and swap reuses the one upload. A publish of the\nsame model reaches the live scene through `api.update({ source })`, which\nreconciles records by occurrence id — records already on screen keep their\nmesh, materials, visual and deformation state and BVH; only new occurrences are\nbuilt and only departed ones disposed (ownership and disposal:\n[resource-ownership.md](resource-ownership.md)). Effects that change vertex\npositions or normals acquire writable attributes before deforming them;\nmaterial refreshes leave component data unchanged. This keeps large assemblies\nfrom duplicating these buffers for display. Assemblies keep geometry in their\ncomponent buffers; they allocate no combined copy of all positions, normals and\nindices. Rendering visits the placed components directly, and\nthe Viewer accepts that component geometry.\n\n### `common/renderModel.js`\n\nUse this module for interactive browser canvases, including the docs hero.\n\n```js\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n```\n\n`renderModel(THREE, model, options)` returns an interactive viewport API:\n\n```js\n{\n  THREE,\n  model,\n  renderer,\n  scene,\n  camera,\n  ready,\n  resize(),\n  render(),\n  start(),\n  stop(),\n  capturePng(),\n  dispose()\n}\n```\n\nCommon options:\n\n- `canvas`: existing canvas for the renderer.\n- `hostElement`/`container`: element used for responsive sizing.\n- `renderer`: caller-owned renderer. If omitted, one is created.\n- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are\n  created.\n- `theme`/`themeSettings`: background and lighting settings.\n- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,\n  `logarithmicDepthBuffer`, `shadows`: renderer controls.\n- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.\n- `pixelRatio`, `maxPixelRatio`: output density controls.\n- `autoResize`: set `false` to disable `ResizeObserver`.\n- `autoStart`: set `true` to start an animation loop.\n- `autoRender`: set `false` to prevent the initial render.\n- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.\n- `disposeModel`: set `false` when the caller will dispose the model.\n\n`dispose()` stops animation, disconnects resize observation, removes the model\nroot from the scene, and disposes the created renderer/model unless ownership\nwas explicitly retained by options.\n\n### `common/renderMeshScene.js`\n\nUse this module for deterministic headless snapshot rendering.\n\n```js\nimport {\n  renderJobContext,\n  modelOptionsForRenderJob,\n  renderModel,\n  captureModel,\n  renderMeshJob\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n```\n\n`renderJobContext(meshData, job)` resolves the shared scene contract plus\nsnapshot-owned output policy: display, camera, quality, scene scale, outputs,\nSTEP topology edge visibility, and warnings. Studio scene quality is Preview's final\nwhen the job asks for `preview`; `job.quality` contains technical tessellation only. A\nfamily scene passes `{ bounds, partCount, explodeParts }` for `meshData`; its job's kind\nresolves the view features (`viewFeaturesForKind`): anything but a STEP has no edges,\nParts only with several parts, and Explode only for a GLB of several.\n\n`modelOptionsForRenderJob(context, job)` converts that policy into\n`buildModel()` settings.\n\nSnapshot `renderModel(THREE, model, { job, context })` holds either a CAD model from\n`buildModel` or a family scene wrapped by `headlessSceneModel`; only a CAD model has a\nruntime, so only it takes the exploded view, topology edges and screen-space line\nwidths. It returns a headless viewport:\n\n```js\n{\n  THREE,\n  model,\n  scene,\n  renderer,\n  orthographicCamera,\n  perspectiveCamera,\n  context,\n  sceneBuildStarted,\n  ready,\n  dispose()\n}\n```\n\nThis `renderModel` is intentionally separate from `common/renderModel.js`.\nRender is drawn through the studio's frame, every sample of it at once\n(`renderStill`: `PHOTOGRAPHIC_STUDIO_FRAME.stillSamples`, 32, re-rendering shadows only for a\nsample that moved the key), with no MSAA of its own: the snapshot supersamples at its render\nscale. A video's frame and a software renderer draw one sample (`snapshotStudioSamples`).\nIt uses snapshot sizing, studio environment, the shared stage floor/glow/shadow,\nmodel-scaled lights with a fitted shadow frustum, canonical camera projection,\nand deterministic renderer settings. Automatic perspective cameras fit the\ncurrent visible vertices to `output.padding` when `output.tightFrame` is true;\notherwise they fit the model bounds. An explicit camera position is never\nreframed. Video capture fits its precomputed sequence-union bounds once so the\ncamera does not breathe between frames. As in the viewer, a still's studio floor\nand its contact shadow stay sized and centred on the rest placement however the\nmodel is posed or exploded (`groundBounds`), and an exploded view radiates from\nthat rest box.\n\n`captureModel(viewport, { job })` returns data only:\n\n- `mode: \"view\"`: PNG data URLs in `outputs`.\n- `mode: \"list\"`: part list and bounds. A CAD model lists its composed part\n  occurrences; a family scene lists what it drew, one row per mesh, in its scene\n  graph's order (a robot's scene attaches a link's meshes before the joints it\n  carries, so its rows run down the tree from the root).\n\nIt does not write files. The CAD skill snapshot CLI writes the returned data to\ndisk. Consumers use compiled `@text-to-cad/core` exports; generated snapshot browser assets\nbundle this entrypoint into cadgen's packaged runtime (`cadgen/_runtime/browser`).\n\n`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,\nbuilds a model, renders/captures it, and disposes owned resources.\n\n### `common/headlessScene.js`\n\nThe snapshot page's half of \"one scene builder per file family\". `headlessSceneFamily(job)`\nanswers, from the job's resolved kind, the family whose loader and builder draw it (GLB;\nSTL and 3MF; URDF, SRDF and SDF), or null for a STEP document. A family is\n`{ load(job, { resources }), build(THREE, loaded, job), release(loaded) }`, each a call to\nthe module the viewer's renderer calls: `loadRenderGlbDocument` + `createGlbScene`,\n`loadRenderMeshByUrl` + `buildMeshScene`, `loadRobot` + `createRobotScene`. A robot is\nposed as it is built, at `robotOpeningPose` (every joint's default, then an SRDF's `home`\ngroup state: where the viewer opens it) with the job's `jointValues` on top.\n\n`headlessSceneModel(THREE, scene, headlessSceneDress(context.sceneSettings))` dresses the\nscene exactly as the viewer's viewport dresses one it adopts, with the look\n`resolveSceneSurfaceLook` resolves (`common/sceneSettings.js`, which the viewport calls\ntoo) and shadows while the lighting is on, and exposes what `renderModel` and\n`captureModel` read of any model: `root`, `bounds`, `restBounds` (what sizes the ground),\n`keepsAuthoredFinish`, the drawn meshes for the tight frame, `listParts()` and `dispose()`.\nThe still's tight frame reads a skinned or morphed mesh where the GPU draws it.\n\n## Kinematics\n\nTwo names, two things, and they are not interchangeable:\n\n* `kinematics` is the POSE INPUT — what `loadSource()` and the snapshot job\n  packet take. A declared preset name, or direct DOF values:\n\n  ```json\n  { \"drive\": 180, \"ringVisible\": false }\n  ```\n\n  Animation envelopes (`animate`, `fps`, `durationSeconds`, `duration`, `loop`)\n  are retired and throw: a still renders one frame at the given values.\n\n* `stepParameters` is the compiled RUNTIME OBJECT that `buildModel()` takes,\n  produced by `stepParameterRuntime(source.stepParameterSource)`.\n\n`common/stepParameters.js` validates the pose values against the loaded\ndefinition and normalizes defaults.\n`loadSource()` uses it to populate `source.stepParameterSource`; callers then\npass `stepParameterRuntime()` into `buildModel()`.\n\n## Examples\n\nInteractive viewer/docs usage:\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource, stepParameterRuntime } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n\nconst source = await loadSource({\n  kind: \"step\",\n  glbUrl: \"/models/.part.step.glb\",\n  sourceSidecarUrl: \"/models/part.step.json\",\n  cadPath: \"models/part.step\",\n  kinematics: { drive: 180 }\n});\n\nconst model = buildModel(THREE, source, {\n  theme,\n  displayMode: \"shaded_edges\",\n  edgeSettings,\n  stepParameters: stepParameterRuntime(source.stepParameterSource)\n});\n\nconst viewport = renderModel(THREE, model, {\n  canvas,\n  hostElement: canvas.parentElement,\n  theme,\n  autoStart: true\n});\n```\n\nHeadless snapshot usage (a STEP document; `headlessRenderEntry.js` routes every other\nfamily through `common/headlessScene.js` into the same `renderModel`/`captureModel`):\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport {\n  captureModel,\n  modelOptionsForRenderJob,\n  renderJobContext,\n  renderModel\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n\nconst source = await loadSource(job);\nconst context = renderJobContext(source.meshData, job);\nconst model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));\nconst viewport = renderModel(THREE, model, { job, context });\n\ntry {\n  const result = await captureModel(viewport, { job });\n  // Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.\n} finally {\n  viewport.dispose();\n}\n```\n\n## Ownership Rules\n\n- Do not write files from shared render APIs. Return data to the owning CLI or\n  application layer.\n- Do not expose object-construction callbacks for edges. Use declarative\n  `edgeRendering`.\n- Keep STEP-only options explicitly STEP-named and reject them for non-STEP\n  sources.\n- Dispose viewports and models that you create.\n- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new\n  shared render code instead of loading assets or constructing render scenes\n  inline.\n- Draw a GLB, a mesh file or a robot with its family's scene builder, in the viewer\n  and in a snapshot alike. Never give one host its own way to draw a family:\n  `scripts/test/check-kit-boundaries.mjs` (`checkSharedSceneBuilders`) refuses it.\n","packages/core/docs/resource-ownership.md":"# Resource ownership and reuse\n\nHow the shared render code owns, shares, reuses and releases the expensive\nthings: component geometry, GPU buffers, BVH accelerators, tessellation\nworkers and cache reservations. Workspace resource reads and their cache\ngenerations are specified in [workspace services](workspace-resources.md).\n\nThe one-sentence laws are in [the package README](../README.md#the-laws-that-live-here);\nthis file is the mechanism each of them constrains. Nothing here may be traded\naway for speed: **reuse is an optimization, never a change in exact geometry,\npersistent cache identity or occurrence identity.**\n\n| § | Covers |\n|---|---|\n| [1](#1-shared-ownership-and-disposal) | More than one scene owner, and who disposes |\n| [2](#2-the-demand-boundary) | Selectors and raycast accelerators built only when asked for |\n| [3](#3-reuse-without-changing-geometry) | Recomposition, instancing, culling, material pass keys |\n| [4](#4-tessellation-workers) | One request per worker, pool growth, reclamation, charges |\n| [5](#5-browser-mesh-cache-admission) | Probe, admit, verify, adopt — and strict reads |\n\n## 1. Shared ownership and disposal\n\nComponent geometry and edge textures can have more than one scene owner; only\nthe last release disposes shared GPU/BVH state.\n\n- Full scene disposal includes host-reparented groups and records attached by\n  an interrupted reconciliation.\n- Cleanup retires completed ownership steps. A thrown disposal remains\n  retryable and cannot release another scene's share twice.\n- Failed initial construction cleans its partial scene before throwing, or\n  transfers the still-owned scene with an explicit cleanup error for retry.\n- Disposable resource admission never changes exact geometry or persistent\n  cache identity.\n- A scene's sibling (`createSibling()` of `lib/render/glbScene.js`,\n  `lib/render/meshScene.js`, `lib/urdf/robotScene.js`) draws over the source's\n  geometry through `lib/render/sharedGeometry.js`: `shareGeometry` gives it the\n  source's index and attribute objects, so one renderer drawing both scenes uploads\n  each buffer once, and copies only what the sibling rewrites for itself (a robot's\n  vertex colours). `releaseGeometry` lets go of the shared attributes before\n  disposing, so only what the sibling owns is freed. The source stays the owner and\n  is disposed after its siblings. A second STEP scene filled from the same published\n  mesh shares component geometry through the owner counts above.\n- `createCadWebGlRenderer` (`common/webglRenderer.js`) takes the `canvas` it draws\n  on (an `OffscreenCanvas`), so one renderer, and one upload of each resource, can\n  serve several views; disposing it and losing its context are its caller's.\n\n## 2. The demand boundary\n\nRender-only loads do not construct selector topology until it is requested,\nand viewport refinement keeps that boundary.\n\n- Unused components replace only display arrays. A component with active\n  topology replaces its selectors at the same concrete tessellation before\n  publishing new triangles.\n- Raycast accelerators may be deferred until a ray reaches a component's\n  local bounds. The first ray uses exact stock intersection; surface builds\n  enter a single worker queue in idle time and are shared by occurrences.\n- Admission covers private position/index copies, worker scratch, and the\n  result before creating an isolate. Display arrays are never transferred.\n  Each worker ends with its reservation; only serialized BVH nodes and the\n  indirect triangle permutation return. Byte accounting includes both the\n  packed BVH nodes and that indirect permutation.\n- Failed or pending builds keep stock picking. A result must match the\n  geometry's attributes, arrays, versions, groups, draw range and live\n  ownership; the last geometry release cancels queued or active work.\n- Deformation runs before the bounds test.\n- The merged edge-pick `LineSegments` (`edgePickRaycast.js`) keeps its one\n  buffer and gains a table of boxes over contiguous 32-segment runs (and\n  1,024-segment groups), built on the first edge ray (O(segments), a tenth of\n  the index buffer) and keyed by the proxy's position array. A ray runs\n  three's own segment test over only the segments whose boxes (group, run,\n  then the segment's own) it passes within the pick threshold of, widened by\n  1e-4 of the ray's reach, far above three's rounding, so the hits and their\n  order are the full scan's. A sync whose proxy only grew (every earlier\n  index and position bit-identical) keeps the built boxes and boxes only the\n  appended segments; any other change builds afresh on the next edge ray.\n\n## 3. Reuse without changing geometry\n\n### Recomposition\n\nRecomposition may take its previous result when the descriptor and component\ninputs are immutable. It shares unchanged occurrence rows and unchanged tree\nmetadata across detail swaps; changed triangle ranges, bounds, placements and\nappearance still produce the corresponding new records. Its private weak\nownership metadata never enters saved geometry or cache identity. Complete\nsame-file revisions can also seed from the prior composition.\n\nDetached occurrence snapshots detect in-place descriptor or material edits;\nonly rows proven to belong to the current composition may skip scene work. A\nstatic delta requires the same last-applied render context and no dynamic\npose, animation or clip. Changed components update their own records, while\nunchanged occurrence geometry and selectors retain their owners. Arbitrary\nmutable caller meshes keep normal reconciliation.\n\n### Selector composition\n\nAn assembly's selector runtime is composed from one part per placed occurrence\n(`buildSelectorCompositionPart`, `createSelectorRuntimeComposer` in\n`lib/selectors/runtime.js`). A part keeps its rows, its references as last\nplaced and the bundle's own proxy buffers with the placement transform — never\na placed copy of the proxy or lookup maps. The composer re-places a part's\nreferences only when its offsets move, and a composition that begins with the\nprevious one extends that result instead of rebuilding it. The result is value\nfor value the whole-runtime `composeSelectorRuntimes` result for the same\ncomponents in the same order (`runtimeComposer.test.js`). An edge's chain\nendpoints are read lazily from the bundle's unplaced positions, placed and\nrounded as the placed proxy stores them; copies of a reference carry the\naccessor rather than reading it.\n\n### Instancing and culling\n\nRepeated compatible opaque surfaces share instanced draws and retain\noccurrence identity; mirrors, transparency and deformation use explicit\nfallback paths.\n\nDetail publications retain compatible surface instance sets and their original\noccurrence slots. Only changed membership or render passes replace those sets;\nselected, hidden and deformed occurrences keep inactive slots until eligible\nagain. Transform passes reuse each mesh's matrix while observing mutable\nsource transforms and effect matrices on every update.\n\nSurface instance groups use aggregate frustum bounds, invalidated by instance\nmatrix and slot changes. Transformed component boxes and conservative parent\naffine padding keep boundary-crossing geometry eligible, including shear.\nCulling changes draw submission only; occurrence slots and picking remain\nintact. Screen-space edge instances retain their separate drawing policy.\n\n### Material pass keys\n\nMaterial pass keys reuse serialized strings only after comparing their current\nscalar values, emission state, render order and clipping planes. Direct\nmaterial and plane mutations remain observable; custom values use ordinary\nserialization. Reapplying material settings preserves unchanged shader programs\nand owned emissive colors. Colour and PBR uniforms still update on every pass;\nfeature changes, vertex-colour mode and transparency invalidate the appropriate\nprogram. Reflection intensity belongs to the shared pass key. A distinct\nnonblack emissive channel, or emission over vertex colours, uses the ordinary\nmaterial so the instance colour cannot tint that channel.\n\nClients that apply pose, selection or material changes directly to display\nrecords finish with `scene.syncSurfaceInstances()`; this uses the same\nreconciliation as a source update. Clip-only viewer updates also synchronize\nthe shared surface material.\n\n### Revision reuse\n\nCanonical geometry trees contain no surface-producer selection. A runtime view\nbinds each component's opaque surface input to a concrete immutable SURF\nobject, and render/selector reuse requires that exact binding plus the lossless\ntolerance pair and payload version. Other snapshot source scopes remain\nisolated. Placement and appearance belong to each tree's occurrence\ncomposition. Viewport L0 is explicitly coarse; L1 preserves the canonical\ndefault mesh options and key. **Changing viewport detail never changes export\ndefaults.**\n\n## 4. Tessellation workers\n\nEach tessellation worker runs one request at a time; excess requests wait on\nthe client. Render sessions lease that shared scheduler. Releasing one owner\nnever cancels another owner; the final release retires remaining work and\nworkers. Idle pressure reclamation preserves active and queued consumers.\n\n- Aborting synchronous work replaces only its worker, preserving other\n  callers. A failed worker request reports an error instead of retrying\n  expensive tessellation on the UI thread. Inline execution is reserved for\n  environments where workers cannot start.\n- A pool starts with one isolate and grows only for ready concurrent requests,\n  or when a package load finds its first components cached: it starts the\n  isolates its lanes will decode on (`prewarmSurfWorkerPool`, at most the\n  lanes' count), so their start overlaps the batch read of those bodies instead\n  of following it. Sequential viewport refinement reuses that isolate until the\n  drain becomes idle; it does not create a maximum-size pool for each component.\n- Pressure reclamation may release idle worker slots while active and queued\n  consumers keep their work. Each live slot retains its own highest completed\n  request estimate, including handled failures; reclamation or replacement\n  removes that slot's charge.\n- Memory estimates stay on the client. They do not enter worker messages or\n  cache keys, and RAM hits add no worker charge.\n\n## 5. Browser mesh-cache admission\n\nBrowser mesh-cache reads start with a bounded metadata probe. The client\nadmits the encoded object and conservative decoded size before fetching a\nbody, binds that fetch to the probed object digest and byte limit, then\nverifies the v4 header and content address before adoption.\n\nA package's open reads its cache in groups, as a snapshot does: one probe for a\nchunk of components and one TESB read for a batch of their bodies, each\ngrowing from the loader's first publish (eight components) to the server's\nbounds (256 keys, 32 MiB), in load order (`packageBatchReads.js`). A client\nwhose transport caps one reply declares a lower ceiling for a batch's bytes\n(`createCadClient({ maxBatchBytes })`, which its cache reports as\n`batchMaxBytes`): the CAD app's tunnel declares 4 MiB, the most one of its\nreplies carries. A ceiling never raises the server's bound\n(`tessBatchMaxBytes`), and a body larger than the ceiling is read alone, as\nevery body once was; that transport carries it a range at a time and hands the\nprovider the whole, which verifies its digest as it does any body's. A batch's\nframed bytes are charged to the Viewer envelope before it is read and released\nonce its last component has taken its body; each component's decode is still\nadmitted on its own before it runs, and an entry the batch could not read or\nverify is that component's strict-read miss alone. The cold components'\nsurfaces resolve up to 64 to a `/__cad/surfaces` request, each the moment its\nown row is ready.\n\nA validated warm entry carries the full surface-object provenance, so\nrendering does not need the SURF object or its derivation index to remain\npresent. `createHttpTessellationCacheProvider` takes an `origin` for hosts\nwhose cache is not on the page's own origin. TESB body groups remain bounded\nat 32 MiB; the Node export provider uses the same immutable `objects/` and\n`index/mesh/` layout as Python.\n\nA caller admitted using a cache probe can request a strict read: a missing or\ninvalid body reports a typed cache miss before tessellation starts. The viewer\nreleases that reservation and probes another cached tier or resolves the exact\nsurface under fresh cold-work admission. **Cache loss never silently turns a\ncheap decoded-mesh request into unbudgeted surface tessellation.**\n\nA client lazily owns one `createTessellationCache({provider})`, whose\n`createSession({signal})` method lends cancellable views to render sessions.\nNo module-global provider can be swapped by another client. Closing a view aborts\nits reads and prevents late results from enqueueing writes; writes already\nadmitted to the client's byte-bounded queue survive file switches. Closing the\nclient disposes the owner, aborts every view and discards remaining queued\nwrites. Viewer write-backs drain in batches with bounded concurrency: after a\nquiet interval, no later than two seconds after a batch's first entry however\nbusy the load, and at once when a batch reaches its byte bound; an entry is\nturned away only while the writer is still busy with the full batch before it.\nSo the queue holds at most two batches of encoded bodies: the one being\nwritten, at most 32 MiB (`TESS_BATCH_MAX_BYTES`, the default `maxPendingBytes`)\nplus the entry that reached the bound, and the one being collected meanwhile,\nat most 32 MiB: about 64 MiB plus one entry in all (`memoryStats()`).\nSnapshot jobs flush and dispose their own cache after their complete source is\nloaded. Decoded component meshes retain their existing page-wide\ncontent-addressed LRU; a cache view does not retain an additional geometry copy.\n\nImported STEP products without faces keep their occurrence identity. A SURF\nthat holds only wires has no loops to measure, so its scale and box come from\nits edge curves. A SURF with no faces or edges tessellates to empty arrays, a\nzero-size box at the origin and a positive minimum scale. Either way the same\nv4 validation applies. That box is only cache metadata: only triangles are\ndrawn, so composition gives an occurrence without them no bounds, and it\ncannot change the assembly's framing or hide the real parts.\n","packages/core/docs/tube-deformation.md":"# Flexible tube animation\n\n`m.get(target).deformTube({rest, path, twistDeg=0, maxSegmentLength=1, braid?})`\nanimates a continuous tube or hollow sheath already present as a swept STEP\nbody. It deforms the original surface and its CAD edge lines in the same shared\npass used by CAD Viewer and snapshots. It does not create a replacement rope or\nchange the STEP artifact. Subsequent rigid occurrence transforms act on the\ndeformed result. The paths use assembly coordinates before those transforms.\n\n```js\nconst rest = {\n  normal: [0, 0, 1],\n  segments: [{kind: 'line', start: [0, 0, 0], end: [10, 0, 0]}]\n};\nconst bent = {\n  normal: [0, 0, 1],\n  segments: [{\n    kind: 'arc', center: [0, 5, 0], axis: [0, 0, 1],\n    start: [0, 0, 0], sweepDeg: 90\n  }]\n};\n// Inside an embedded animation clip update(t, m):\nm.get('tendon').deformTube({rest, path: bent, twistDeg: 360 * t});\n```\n\nEach path is `{normal, segments}`. `normal` is REQUIRED on both the rest and\nthe posed path: it seeds the transverse frame at the first point (projected\nperpendicular to the first tangent; later frames use parallel transport). There\nis no default — a guessed seed would flip as the first tangent turns during an\nanimation and twist the tube by 90 degrees in one frame — so an omitted or\ntangent-parallel `normal` throws. Every segment must meet the next at the same\nposition and tangent. Discontinuities and corners throw a teaching error.\n\nThe closed segment vocabulary is:\n\n- `{kind:'line', start:[x,y,z], end:[x,y,z]}`\n- `{kind:'arc', center:[x,y,z], axis:[x,y,z], start:[x,y,z], sweepDeg}`.\n  Axis is a nonzero normal. Sweep is signed, nonzero, and at most 360 degrees.\n  Start lies in the circle plane and determines its radius.\n- `{kind:'bezier', points:[p0,p1,p2,p3]}`. This is a cubic Bézier with four\n  finite vec3 control points. It uses adaptive five-point quadrature for length\n  and numerical parallel transport; stationary tangents and cusps are errors.\n\nThe runtime projects each original mesh vertex onto the analytic rest\ncenterline and retains its longitudinal coordinate and transverse offsets.\nNormalized arc length maps to the posed path; length changes therefore stretch\nor contract the visible tube. This is a display mapping, **not a tendon tension,\nspool payout, friction, collision, or constant-length solver**. An author must\nsolve those mechanics and pass the resulting centerline. Tube thickness remains\nunchanged. `twistDeg` rotates each cross-section in its transported frame; it can\nshow phase travel in an authored helical braid without moving the centerline.\nIt does not itself simulate material payout.\n\nThe source STEP tessellation is immutable. Each occurrence retains a rest\nmesh and one reusable displayed mesh; unchanged component buffers remain shared.\nStraight STEP surfaces may contain only\nend rings, so the runtime first splits their existing triangles into bands of\nat most `maxSegmentLength` mm in rest arc length. This preserves the exact rest\nsurface, interpolates attributes, preserves CAD face picking, and prevents an\nanimated bend becoming a single end-to-end chord. The default is 1 mm; values\nbelow 0.05 mm are rejected. A per-occurrence 700,000-triangle ceiling throws\ninstead of exhausting memory. Refinement is cached until the rest path or band\nlength changes. Original smooth normals are transformed by the deformation's\nlocal inverse-transpose; silhouettes and technical edges follow the surface.\n\nEvery evaluation starts from rest. Omitting `deformTube` on a later frame or\nclearing animation restores the rest shape. Shared component meshes are never\nmutated. A changed centerline can be independently inspected without a renderer:\n`evaluateAnimationClip` returns `deformations`, keyed by occurrence ID. A\ndeformation is the NUMBERS — `restSpec`, `pathSpec`, `twistDeg`,\n`maxSegmentLength`, `braid` — validated and owned, never the author's arrays and\nnever a compiled path: a morph bake fits on a 96 Hz grid, and a sample that held\nits two arc-length tables would hold hundreds of KB per tube per grid sample.\n`compileDeformation` resolves the two paths through a bounded LRU, for as long\nas the caller is posing with them; `sameTubeDeformation` and `sameTubeRestShape`\ncompare two deformations by value. `compileTubePath`, `sampleTubePath`, and\n`projectTubePath` are exported from `common/tubeDeformation.js`. Line and circle lengths/curvature are analytic;\n`minRadius` for a Bézier is a sampled diagnostic and must not substitute for an\nindependent curvature gate. As with any CAD tessellation, a finite surface mesh\napproximates the analytic posed centerline between its vertices.\n\nFor a smooth CAD core, `braid:{pitch:0.8, depth:0.02, strands:8}` adds a\nprocedural braided surface finish. Pitch and normal-relief depth are millimetres;\nstrands is an even carrier count from 2 to 64. Crossing helices, fine fiber\nlines and lighting relief use rest material coordinates and follow\n`twistDeg` as the tube deforms. This is a GPU normal/color finish, **not added\nCAD fiber bodies or collision geometry**; it remains inside the nominal core's\nrendered envelope. No image texture is fetched and no helical solids are\ncreated. The finish is scoped to that occurrence's material and disabled when\nthe effect is omitted. The original material's source color and shader hooks\nremain in force. Both shader stages — the GPU vertex transport and the braid\nfinish — install through one shared hook (`tubeMaterialShader.js`) that runs\nthem in a fixed order and hands the braid its rest coordinates through one\nvarying, so enabling the braid on a later frame than the deformation renders\nthe same as enabling both at once.\n\n## The runtime is a lazy chunk\n\n`deformTube` is the only producer of a tube deformation anywhere in this\npackage — a step module's effects carry only what an animation frame already\nput there — so a document with no `animation.source` can never reach this code\nand must never download it. `common/tubeDeformationChunk.js` is the boundary:\n`tubeDeformation.js`, `tubeGpuDeformation.js`, `tubeBraidMaterial.js` and\n`tubeMaterialShader.js` load behind one dynamic import, ~29 kB out of the CAD\nViewer's initial bundle.\n\nThe load happens at the single async door every clip must pass through:\n`compileAnimationSource` (and so `loadSourceAnimation`, and `mesh-export.mjs`)\nawaits it before any clip object exists. Declaring an animation is the gate,\nnot calling `deformTube`, because a clip may reach for a tube at any `t` and a\nframe must never fall back to the rest pose. Evaluation itself stays\nsynchronous and always sees a loaded runtime, so an animated tube renders\nexactly as it did when this was a static import.\n\nCallers outside the animation path — the scene's effect resets, the display\nedge-line pass — go through `tubeDeformation()`, which answers `null` until the\nchunk lands. That is exact rather than merely tolerant: every one of those\ncalls is a reset or a replay, and a record can only hold tube state after a\ndeformation was applied. `deformTube` cannot no-op, so it throws through\n`requireTubeDeformation` instead. Code that builds clips by hand rather than\nthrough `compileAnimationSource` — tests, mostly — awaits\n`loadTubeDeformation()` first.\n","packages/core/docs/workspace-resources.md":"# Workspace CAD services and resources\n\n`CadWorkspaceService` is the renderer's domain boundary. A host constructs one\nservice per workspace and lends it to every mounted CAD view. The HTTP adapter,\n`createCadClient`, implements this contract and retains its catalog, request,\ncache and worker-session lifetimes. Disposing a view releases its render session;\ndisposing the service aborts its resources and sessions.\n\nSurface resolution and editing-preview observation belong to the service.\nThe HTTP implementation owns request encoding, bounded polling, subscriber\ncancellation, replacement views and immutable store URL validation. A pending\nderivation is asked after again at 80 ms, doubling to 640 ms, then at a tenth of\nthe wait so far, at most every 5 s: a derivation stuck behind a daemon that cannot\nstart a worker is not asked many times a second until it fails. A surface\nbinding keeps its tree/view, surface input and concrete object identities;\na mismatched view, input, object or URL fails before publication. UI consumes\nthe resulting tickets and never supplies a same-origin transport fallback.\n\n`service.resources` owns JSON, text, binary and size reads, dependency resolution\nand worker tickets. Pass it as `{ resources }` to render loaders, peeks, sidecar\nloads and robot loads. Relative package assets and URDF/SDF mesh references are\nresolved by the provider. Direct glTF buffers and textures are read through it\nas well; the Three.js loader receives temporary object URLs that are revoked\nonce parsing completes. Up to six nested glTF reads run together, each limited\nto 64 MiB. Workers broker nested references back to the owning provider.\n\nA normal HTTP provider gives workers serializable URL tickets, preserving the\nexisting worker fetch/decode path, including the configured HTTP cache policy.\nConfigured headers stay on the provider origin; external nested references do\nnot inherit them. Custom fetch implementations remain host-owned. A provider with a custom fetch reads bounded\nbytes on its owning thread and transfers an owned ArrayBuffer to the worker.\nThe worker never receives a service function or detaches an admitted cache\nbuffer. SURF byte tickets are acquired only after reserving a worker slot, so\nqueued components cannot accumulate eagerly transferred buffers. Recognition reads are limited to 16 MiB. SURF display-cache hits inspect\nthe cached header before requesting a resource ticket, preserving zero-SURF-read\nwarm rendering. A miss with no SURF URL yet (a part that opened warm, before its\nsurface is resolved) fails as not ready and requests no ticket; the caller resolves\nthe surface and asks again. Aborting a worker request retains the existing scheduler and\nowner rules in [resource ownership](resource-ownership.md).\n\nEach service wraps its provider in an opaque cache generation. URL-addressed\nJSON, text, mesh and descriptor caches include this scope; completed package and\nrecognition caches do too. A fresh server-metadata read that observes a changed\nidentity token retires the generation and aborts its pending reads.\nThe provider exposes that generation signal on the main thread. Worker clients\nand recognition subscribe before acquiring tickets, so already-issued URL\ntickets cannot publish a late result after retirement. The signal itself never\ncrosses into a serializable worker message.\nReplacement services and authentication providers cannot inherit another\nservice's URL cache. Mounts borrowing the same service retain warm reuse.\nContent-addressed decoded tessellations retain their existing exact input/object,\ntolerance and payload-version keys and bounded geometry ownership.\n\nThe public low-level core loaders continue to support omitted providers for\nstandalone static HTTP callers. This is an explicit transport API default,\nusing ordinary fetch and URL-addressed caches; it is not a mutable process-wide\nworkspace provider. Shared UI requires its injected service. `loadSource` is the\nstatic/source composition boundary and creates an HTTP provider when none is\nsupplied; docs and the snapshot entrypoint compose theirs explicitly. Hosts\nrequiring custom credentials, native transfer or workspace isolation must pass\na provider consistently to both loads and peeks.\n","packages/ui/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2FREADME.md -->","packages/ui/docs/annotation.md":"# Annotation\n\nThe drawing tools — Pencil and Shapes — are the CAD viewer's own ink, with no drawing SDK behind it. A view has one\nannotation: its marks are projected into the model in 3D, so they stay where they\nwere drawn from every angle, lying on the surface they were drawn on. The STEP, GLB, mesh, robot and DXF renderers offer them\n([Pencil and Shapes](cad-renderer.md#pencil-and-shapes)).\n\n## Pieces\n\n- `annotation/session.js` — `useAnnotationSession`: the annotation's ink, what the tools\n  put down next (the tool of the strip tool in hand, `pencil` — the pen, the highlighter or the eraser — and `shape` — the line first, the arrow, rectangle, ellipse or text — each kept while the other is up; `style`, the colour, stroke width and\n  placement — `surface`, the default, or `plane` — of the tool in hand, kept per drawing kind in `styles`: `pen`, `highlighter` (yellow\n  `#ffe600`; the others neon red) and `shapes`, which all five shapes share; the eraser has none) and the selection. Each add, erase, restyle of the selection\n  (`restyle`) or fill of a closed shape (`fill`, joining its group) is one step\n  in the file view's history (`renderers/kit/shell/useViewerHistory.js`, 200\n  steps, shared with the renderer's own tools; without one the session keeps its\n  own). Choosing what is drawn next never touches ink already down.\n  `clear()` is a step; `discard()` drops the ink and takes its steps out of the\n  history, for a model that changed under it. The session also keeps the views ink was drawn\n  in (`views`, \"view 1\", \"view 2\"…), as labels for the agent: `noteView(view)` names the one a mark goes\n  down in — a view drawn in before that is the same (`annotation/views.js`'s\n  `sameView`: less than 2° of turn, 5% of zoom or pan, nothing else changed),\n  or a new one.\n- `annotation/views.js` — a view as a prompt carries it, in a snapshot job's own\n  words: the camera (always orthographic), Clip when on, the one motion on\n  screen (Explode, or the pose off its default as `kinematics` or\n  `jointValues`), the parts hidden and isolated, and the picture's size. Never\n  how it looks.\n- `annotation/tools.js` — the two strip tools that draw and their tools: Pencil (`freedraw`, `highlighter`,\n  `eraser`) and Shapes (`line`, `arrow`, `rectangle`, `ellipse`, `text`); `toolFamily(tool)`, `styleSlot(tool)` (the style slot a tool draws with; none for the eraser).\n- `annotation/toolbar.jsx` — `AnnotationToolbar`, the sub-toolbar both strip tools share: one row (at every width; a phone's\n  wraps where it has no room) — the tools of the strip tool that is up, a button each, the one in hand pressed (Pencil's Pen,\n  Highlighter, Eraser; Shapes' Rectangle, Line, Arrow, Ellipse, Text); a rule; Color, Stroke width and Placement (On\n  surface, On plane), dropdowns of the tool in hand's own style (disabled under the Eraser), Stroke width's glyph a straight line drawn at the weight (four, 1–6px, unnamed in its menu; Color's and its menus have no heading), then Clear;\n  Undo and Redo are ⌘Z and ⇧⌘Z, with no buttons. A flat view (a DXF) passes `placements={false}`: its ink has nowhere to lie but the drawing.\n  `annotationColors` offers\n  neon swatches and a neutral that reads against the theme: white in dark mode,\n  near-black in light mode. A native color picker adds a custom color.\n  `AnnotationStylePickers` also restyles the selection's ink.\n- `renderers/kit/tools/annotate/AnnotationSelectionBar.jsx` — the bar over the ink Select\n  picked: its colour and weight, Fill for a closed shape (seeded\n  inside its outline as it falls on the screen, `annotation/geometry.js`'s\n  `interiorPoint` and `fillMarkAt`, and projected where the shape lies: On plane on the\n  shape's own plane if its points are coplanar (a fitted plane), else On surface) and\n  Delete.\n- `annotation/geometry.js` — each tool's mark in screen space:\n  - The highlighter (`highlighterMark`) is the pen's smoothed line at one broad width — five times the stroke width\n    (`HIGHLIGHTER_WIDTH_FACTOR`) — 35% opaque (`HIGHLIGHTER_OPACITY`), round-ended, with no `widths`: an ordinary\n    stroke mark (`width`, `opacity`) that no new field or kind describes. `strokeWidthAt` and `strokeSettingOf` convert\n    between a stroke's width and the setting (1, 2, 4) for restyling. Translucent strokes are drawn once-only\n    in the model (`core/common/annotation.js`: a stencil reference per stroke), so its overlapping segments do not darken.\n  - The pen is smoothed as it is drawn, and each point's width comes from its\n    pressure (a stylus's own, or simulated from speed with a mouse), using\n    `perfect-freehand`.\n  - Rectangles get rounded corners.\n  - A fill finds the area inside a shape with\n    `@text-to-cad/core/lib/annotation/fillRegion.js`.\n- `renderers/kit/tools/annotate/AnnotationOverlay.jsx` — the surface over the viewport\n  while Pencil or Shapes is up. It turns every gesture into marks, previews a mark on its\n  own canvas as it is made, and puts it into the model when the gesture ends.\n  Text is written where the surface is pressed: Enter, or a press elsewhere,\n  puts it down as a text mark; Shift+Enter makes a new line; Escape drops it. The\n  surface stops the press's mousedown, which would otherwise take the keyboard\n  back from the text it just opened.\n  Each mark names the view it was put down in, and is projected onto the surface it starts on.\n  The drawing tools have no Select of their own:\n  the viewer's Select picks ink (`RendererShell.jsx`, through the viewport's\n  `annotationAt`), Shift adding to the pick, and Delete and Escape act on it (here too).\n  The camera stays the person's between strokes: the wheel zooms, a right or\n  middle drag orbits, and while Space is held the pointer is the view's.\n- `mountAnnotations(runtime).update(items, selected, hovered)` haloes the ink Select\n  picked, a wider line in the selection's blue behind it, and, fainter, the ink\n  its pointer rests on (the shell's `hoveredAnnotation`, the marks drawn with it too). Ink draws after the model and its\n  highlights (`ANNOTATION_RENDER_ORDER`), so a lit part never paints over it. Nothing else is drawn: no\n  pane, no label. The ink's box (`runtime.annotationBounds`) widens the camera's depth range.\n- `renderers/kit/tools/annotate/annotation3d.js` — projects a mark into the model.\n  - Every point stays on the ray through where it was drawn, so from that view the ink is\n    exactly what was drawn. **Ink lies on the surface it is drawn on.** Every mark has a\n    **level**: the plane of the surface it starts on (a stroke's first point, a text mark's anchor\n    corner, a fill's first outline point), found by `levelOf(runtime, start, size)`. A flat face's\n    level is its own plane; a curved face's, its tangent plane at the start point (the geometry's\n    smooth normal there). The normal is the surface's, **not the view direction**: from any\n    angle, a mark started on a plate's top has the plate's top plane as its level. No sheet, no\n    pane: nothing is made per view, and nothing is held between marks.\n  - **Placement** says what a point does, per mark at draw time. *On surface* (the default): each\n    point drops onto the visible surface under it (a stroke follows a curved face and steps across\n    faces); a point with nothing under it (past the model's edge, over a through-hole) takes the\n    level: its ray met with the level plane, so a stroke that trails off a face carries on at that\n    face's level. *On plane*: every point is its ray met with the level plane — flat across a\n    curved face, staying on the tangent plane where it started, and carried off the surface at\n    the same level. Both modes agree off the surface. A stroke's line is first split into pieces\n    of a few pixels (`subdivideStroke`) in both, so a two-point line reads the surface all along\n    it; the pen's widths are split with it. In On surface a point takes the nearest depth within\n    its own reach — half the stroke's width and a pixel, never a neighbour's, so a slanted face\n    does not lift the ink off it; a fill point by point. Every point is then two screen pixels toward\n    the eye, text eight. Text is a card facing the view, at the level's depth under its anchor\n    corner, in both modes. Ink already down never moves with the setting.\n  - **The level, when the start is over nothing** (in order):\n    1. the surface under the start point (a ray through it);\n    2. the **visible surface nearest to the start on the screen**: in a depth render of the parts\n       over the window (read back at no more than 256 pixels on the long side), the covered pixel\n       with the least screen distance to the start, found by searching outward in rings of pixels\n       from where the start falls (the nearer of two as far wins), then ray-cast through its middle\n       for the surface point and normal there;\n    3. nothing drawn in the window, but a model: the drawn surface **nearest to the window on the\n       screen** — the same render over a region round the window, large enough to hold the model's box\n       on screen (the window's size on each side where part of the box is behind the eye), at low\n       resolution; the covered pixel with the least screen distance to the window rect;\n    4. no model drawn: the plane through the origin facing the view.\n\n    \"Drawn\" is what the depth render sees: hidden parts and what Clip cuts away do not count.\n    Clip's cap faces are not part meshes (stencil fills, `sectionCaps.js`), so they are not in\n    the render: through a cut the level is the surface seen inside. Without a GPU float render the\n    same reads come from rays, at a far lower resolution. The depth renders are made only when a\n    level needs them, once for all the marks put down together (`projectAnnotations`).\n  - **Grazing guard:** a level whose plane is nearly edge-on to the view (|normal · view\n    direction| below 0.08) is replaced by the plane facing the view through the same point, else rays\n    would stretch the mark along the plane.\n  - `projectAnnotations(runtime, marks, size, { placement, topologyAt, level })` projects marks,\n    finding each one's level inside; `level` ({ point, normal }) gives all of them one instead (a fill\n    of a flat shape lies on the shape's plane).\n  - What the model hides of the ink is drawn faint through it (core's `ANNOTATION_HIDDEN_OPACITY`,\n    on the viewer and in snapshots), so a profile drawn through a part's middle is seen.\n  - Every mark is in model coordinates: it stays where it was drawn whatever\n    Explode, Position or hiding a part does to the model. Clip cuts it.\n    `touches` says what it was drawn over, most first, the same for either placement: the faces and edges the\n    renderer names under it (`ref`, a prompt reference, from the shell's\n    `inkTouches`; the STEP renderer's reads them whatever Select's filter picks),\n    each with its part; without it, the parts.\n  - The projected ink is plain data (`AnnotationItem`): an annotation mark\n    (`@text-to-cad/core/common/annotation.js`), kept across scene rebuilds.\n    `mountAnnotations` draws it in a viewer runtime and `update` draws only the\n    marks a change adds; the objects are core's, the ones a snapshot draws an\n    annotation with. The preview\n    holds a finished mark until the scene has drawn it. `annotationDocumentOf` writes the\n    annotation as an annotation file. The file format is unchanged: marks are 3D points; the level is the viewer's own.\n  - A double-click under Select (Look at, `kit/camera/lookAt.js`) is navigation only; it sets\n    no plane for ink.\n\n## Lifetime and capture\n\nThe live annotation is session-only: it is never written to tab or file state. It\noutlives the drawing tools being put down, because the ink stays in the model and the next\none adds to it. It goes when the person clears it, or when the model it was\ndrawn on changes.\n\n⌘C / Ctrl+C while Pencil or Shapes is up copies the view as it is on screen, ink\nincluded, to the clipboard as a PNG.\n\nQuick Edit captures the current view and distinct views with remaining ink when\nCopy Prompt, Queue or Send is pressed (`host.promptFiles`). The snapshot packet\nincludes shared annotation and measurements: up to 1 KiB of compact UTF-8 JSON\ntravels inline, and larger packets are saved to a temporary `.snapshot.json`.\nNo images are rendered at handoff. Save inline JSON to a file before running\n`cadgen snapshot --job <file>` to render those views over the model or an edit of it.\nThe Annotation chip does not copy references or save individual files.\nQueue and Send clear the annotation with the note; Copy Prompt keeps it.\n","packages/ui/docs/cad-renderer.md":"# CAD renderer\n\nThe CAD viewer is the viewport, the floating tool strip and the tool in hand's\nsub-toolbar under it, the file sidebar, reference interactions, measurement and annotation\ntools, animation, loading artwork and alerts, in `@text-to-cad/ui`. The rules of its\nchrome — which tools a file has, how they take and give back the pointer, the file sidebar,\nPreview, the keyboard — are the\n[design system](settings-ui.md); this document is the mechanism behind them.\n\nThere is one renderer per file family over a shared, format-blind [kit](#kit) and\n[shell](#shell). The STEP renderer is under `src/renderers/step`, on the kit like\nevery other family: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`, which\nruns `useRendererShell` exactly as the other renderers do and composes STEP's own\nmodules around it (see the end of [Shell](#shell)). Applications use the registrations and the shared\n`FileViewer`; they do not import another application's source.\n\n## Kit\n\n`src/renderers/kit` is the format-blind half of the viewer: small modules a\nrenderer composes, none of which asks what it is showing. There is one renderer\nper file family, each a vertical slice over the kit: `src/renderers/dxf`\n([DXF renderer](#dxf-renderer)), `src/renderers/glb` ([GLB renderer](#glb-renderer)),\n`src/renderers/mesh` (STL and 3MF, [Mesh renderer](#mesh-renderer)),\n`src/renderers/robot` (URDF, SRDF and SDF, [Robot renderer](#robot-renderer)) and\n`src/renderers/step` (STEP and STP, [STEP renderer](#step-and-source-separation)). The kit imports itself, shared UI\n(`primitives`, `lib`, `drawing`) and the format-blind half of `@text-to-cad/core`\n(`lib/viewer/*`, `lib/perspective.js`, `common/viewSettings.js`,\n`common/sceneSettings.js`, the Render studio); a renderer imports the kit, never\nthe reverse.\n\n| folder | what it is |\n| --- | --- |\n| `viewport/` | `useViewerRuntime` (three.js renderer lifecycle, on-demand render loop and `requestRender`, resize and device-pixel-ratio caps, context loss, keyboard orbit, `setSuspended`, teardown), `gpuDevice` (the page's one WebGL context, shared by every viewport: see [the GPU device](#the-gpu-device)), `previewViewScene` (Preview's view of the file's Render settings: no clip or explode, one scene-quality tier up, `previewSceneQuality`), `framePresentation`, `viewportBuffer`, `renderDepthPolicy`, `sceneObjects` (`disposeSceneObject`), `thumbnail`, DOM helpers. The scene in the viewport is its owner's: teardown calls the injected `disposeScene(runtime)` and `disposeStudio(runtime)` and disposes its lights' shadow maps; a runtime with a renderer of its own (no device) then loses its WebGL context (`forceContextLoss`), since three's shared textures keep every renderer that drew them reachable and only a lost context gives its GPU memory back, and one on the device lets go of its view and never ends the device's context. A torn-down runtime draws no further frame. The orbit controls let go of the page in a layout cleanup, before React detaches the canvas: they listen on its root node, which is no longer the document once it is detached. |\n| `camera/` | `runtimeCamera` (zoom percent against the authored framing, projection and lens sync, perspective snapshots, eased transitions, fit-to-bounds, recentre), `useViewportCamera` (that behaviour bound to a mounted viewport: the perspective kept by a mounted view, the reset that Zoom to fit and the live `resetCamera` share, view-cube presets, which turn the camera and keep its zoom and target), `viewportCameraKit` and `viewportCameraFit`, `orbitControls`, `zoomPivotReanchor` (a wheel step first moves the pivot's depth onto the surface under the cursor, and a perspective pan is scaled by that surface's depth, so Render pans and zooms what the cursor is on at Solid's rate), `zoomSpeeds`, `cameraLens`, `ViewPlaneControl` (view cube). |\n| `look/` | `stageEffects` (lighting rig scaled to the model, floor, glow and shadow catcher, grid and origin axes), the Render studio boundary (`renderStudioChunk`, `studioEnvironmentCache` and its worker). `chromeBackdrop` and `useChromeBackdropColor` (the frame colour around a scene). The surface LOOK is data the viewport resolves and a scene applies to its own materials: `@text-to-cad/core/lib/viewer/surfaceLook.js` (`createSurfaceLook(THREE, root).apply(look)`) does it for any authored material tree. The viewport resolves it with core's `resolveSceneSurfaceLook` (`common/sceneSettings.js`), the resolver the snapshot CLI dresses the same scenes with. |\n| `view-settings/` | The settings model and store (`viewSettingsStore`, `useViewSettings`, `viewerDisplaySettings`, `renderState`), applying a change to a viewport (`useAppliedViewSettings`, `viewUpdateCoordinator`, `viewUpdateGate`, `viewUpdatePlan`), the content of Display's dropdown (`DisplayModeOptions`: the modes and the Grid switch's icons; `DisplayPopover` draws it), and Preview's settings form (`PreviewRenderSettings`). |\n| `tools/` | `FloatingToolBar` (the dumb strip: the tools it is handed, in groups parted by one thin rule, then, only where the host has no navbar, the view's controls, which end the last section the tools have with no rule of their own; after a rule only where the tools are a single group), `SubToolbar` (the tool in hand's one row under the strip, its groups parted by `SubToolbarSeparator`; `SLIDER_SUB_TOOLBAR_CLASS`, the one width of a slider tool's row), `toolModes` (the tool-mode state machine), `ToolModeMenu` (`ToolSettingsMenu`: a tool's settings dropdown, from a chevron right after its button on the strip, or a sliders button in a panel's heading), `ToolPopover` (an ordinary dropdown from its button, on a `side` and `align`ed start or end: the Animation section's Playback settings), `FileSidebar` (the file's sections, whatever the tool: a tile each at the top-left, a tile opening its section in a panel under them, one at a time, sized from its bottom-right grip; on a phone one tile, `phone`, opening one panel with the sections as tabs and its one X; Preview offers its Position and Animation alone, `sections`, its tiles `bare`), `ToolStack` (the bounded column under the strip and sub-toolbar for a renderer's own `toolPanels`, which scrolls only when what cannot give way still does not fit), `ToolPanel` (one panel of the stack, or one section of the sidebar: `fit` says how it gives way when the viewer is short, `resizable` makes a stack panel the person's to size by the grip in its bottom-right corner alone, moving only it, and a panel with a heading can fold to it; its `footer`, a `ToolPanelFooterButton`, is a full-row action under the body that never scrolls), `ResizeGrip` (the one resize grip of a box over the viewport, in a bottom corner: the sidebar's and a resizable panel's at the bottom-right, Quick Edit's at its bottom-left), `toolStackLayout` (every panel's width, each sidebar section's and the resizable panels' sizes, and the folded stack panels: defaults, bounds and their stored record), and the format-blind tools: `annotate/` (`AnnotationOverlay`, the surface the drawing tools' gestures land on; `annotation3d`, its ink projected into the model; `AnnotationSelectionBar`, the bar of the annotations Select picked), `PreviewChrome` (Preview's chrome: the tools view's controls hidden and inert while it is up, and its own corner, `end`, with its settings panel under it, `panel`), `PlaybackMenu` (the Playback settings — Speed, Loop, Autoplay — and `ORBIT_SPEEDS`, Preview's Orbit speeds), `playbar/` (`AnimationSection`, the file sidebar's Animation section: Reset in its heading, the routine where there are several, Play/Pause, the scrubber, Playback settings; `AnimationTimeControl`, the scrubber, and `animationControlsHaveContent`; `animationClock`, `usePlaybackFrames`, `playbackPreferences`: the file's playback settings — Orbit's speed, Autoplay, and the chosen Speed and Loop), `pose/` (the handle overlay, canvas, drag mathematics), `select/` (`SelectFilterMenu`, Select's selection filter on its chevron; `selectFilter`, its pure toggles; `usePointerPick`: taps and hover through a scene's own `pick`, each with where the pointer was; `useSceneSelection` and `sceneReferences`: the parts a scene's pick names, selected, lit and handed to Quick Edit as references), `measure/` (Measure, for any 3D view: `useMeasureSession`, the session; `useMeasureOverlay`, its drawing; `MeasurementCard`; `measurementChips`, its chips and its row under the strip (`MeasurementsBar`); `measureSelection`, Select's press and hover on a measurement; `SceneMeasure`, two points on a scene's surface, and the model's size row), `model-effects/` (Explode and Clip, for any scene that opts in: `ModelTools`, their strip tools and slider rows; `useSceneEffects`, the viewport applying them to the scene; `motionExclusion`, one motion at a time), `sceneTools` (the tools a scene's renderer composes around the shell: the tool in hand, Pencil and Shapes, Measure and the model effects), `quick-edit/` (`QuickEdit`, the note to the agent, and `quickEditPrompt`, the context it builds and the text it copies). Screenshot capture is `@text-to-cad/core/lib/viewer/screenshotCapture.js`. |\n| `inspector/` | `FileSheet` and its row and section primitives, `modelTreeSearch` (`useTreeSearch`, the ranked flat search every tree shares), `VirtualRows` (a long tree's rows, windowed), `referenceRows` (`InfoRow`, `MonoValue`, `CoordValue`), `kinematicsControls` (the `Pose` row that heads every Position section: Default, the named poses, Custom). The tree row and filter box are `primitives/tree-row` and `primitives/tree-filter`. |\n| `status/` | `LoadingIndicator` and `ViewerLoadingOverlay`, `ViewerAlertCard` (the card over the viewport for every alert, and `useAlertDismissal`: a card put away, and its icon in the navbar that brings it back) and `reportIssue` (`alertIssueUrl`: its Report Issue's new issue), `ViewUpdateStatus`, `loadingState` (`viewerLoadingState`), `loadAlerts` (`failureAlert`, `noGeometryAlert`). |\n| `shell/` | The host glue every renderer needs that is not about its scene: see [Shell](#shell). |\n\n**What a view opts into.** Display's settings and `resolveViewSettings` take explicit\nlists, `features: { modes, settings }` (`ViewFeatures` in\n`@text-to-cad/core/common/viewSettings.js`): the modes it lists and the settings\nits menus offer (`VIEW_SETTING_IDS`). A setting left out does not affect the\nscene, and saved settings are never rewritten. `viewFeaturesForKind(kind, { parts })`\nis the one rule of which list a file gets: `ALL_VIEW_FEATURES` for a STEP;\n`PARTED_MESH_VIEW_FEATURES` (Solid, Clip and Explode) for a GLB of several rigid nodes\nthat draw; `MESH_VIEW_FEATURES` (Solid and Clip) for any other 3D file; and\n`EDGELESS_VIEW_FEATURES` (no Edges, Clip or Explode) for a drawing. Render is no mode. Every\nrenderer hands its list to the shell, which configures the view-settings store\n(`configure({ features })`) and Display's settings from it, and to its Explode and Clip\ntools. The headless renderer (`renderMeshScene.js`) resolves a job of each family under the\nsame rule.\n\n**Tools.** The strip draws the list it is handed: `{ id, label, icon, active,\ndisabled, onSelect, description?, menu? }` — a press is a tool's only action; a tool with\nsettings has them as `menu`, a chevron right after its button (Select's filter, the only\none), and what it can be set to while it is up is its sub-toolbar. Display is not a tool:\nits settings are a dropdown from its button at the navbar's right end, before Preview\n(`kit/shell/DisplayPopover.jsx`), a 3D view's alone. A renderer\nbuilds its own list from `shell.tools.own(...)`, adding `shell.tools.drawing` (Pencil and Shapes) where it offers\nthem, declared in its `toolModes` as two modes (`pencil`, `shapes`) (a press on the tool in hand does nothing); STEP's is in `step/StepSurface.jsx`.\nRoutines play from the file sidebar's Animation section, which the shell adds for any file with\nroutines (`kit/tools/playbar/AnimationSection.jsx`), in the tools view and Preview alike. Preview is not a tool: its button, after Display at the navbar's right end, enters it. Nor is Quick Edit: it is a box at the top-right, there while something is picked or annotated. `createToolModes({ defaultMode, modes })`\nanswers what a press does (`next`), what a saved tab may record (`persisted`) and\nwhich tool a file opens in (`restore`); the STEP declaration is\n`CAD_TOOL_MODES` in `workbench/constants.js`. The scrubber follows the clock on\nthe runtime it is handed (`runtime.clock`, an `AnimationClock`); Position's knobs\noverlay (`tools/pose/`), Select's while its filter ticks Position, takes a plain handle list, as a prop or (for an\nowner that poses its model outside React) as a ref whose list it replaces per pose.\n\n**The scene contract** (core's `lib/viewer/sceneContract.js`, a JSDoc typedef that `kit/scene.js`\nre-exports, because the snapshot CLI holds the same scenes): `{ object3D, bounds,\nrestBounds?, dispose(), setSurfaceLook?(look), setShadowReception?(receives),\nkeepsAuthoredFinish?, complete?, pick?(ray), placedObjects?() }`, and for a family's scene\nthe tools' half: `units` (the file's unit against the scene's coordinates, core's\n`sceneUnits.js`), `setClipPlanes(planes)`, `explodeParts` and `setExplode(amount)` (a GLB),\nand, for a scene whose parts are picked by an id of the file's own, `hasPart(id)`,\n`describePart(id)` and `setHighlight({ hovered, selected })`.\nThe viewport adopts `object3D`, frames `restBounds`, lights and floors `bounds`,\nand hands over the surface look the Display settings resolved\n(`{ materialSettings, authored, surface: { style, opacity } }`); it only ever\ndetaches a scene, whose owner disposes it. `buildModel`\n(`@text-to-cad/core/common/cadScene.js`) exposes `object3D`, `bounds`, `restBounds`\nand `dispose()`; the GLB and mesh renderers' scenes implement all of it but `pick`,\nand the robot renderer's all of it. A family's `pick(ray)` answers the first surface Clip has\nnot cut away, with the point it hit (`point`, for Measure). `pick(ray)` is what `usePointerPick` calls: the\nhook owns the pointer (which press is a tap, one hover pick per frame, the\ncursor), the scene says what is under the ray, and its renderer says what a hit\nmeans. A tap acts at once: nothing waits to tell it from a double-click.\n`placedObjects()` is for the near/far fit alone: a camera inside a mostly empty\naggregate box can still be well outside everything visible in it, so a scene that\nplaced many things says so and is fitted on them instead of on its whole box.\nShadow reception is the viewport's for every mesh of a scene, unless the scene\nimplements `setShadowReception`: a scene with unlit or see-through surfaces is told\nthe setting and applies its own rule (a STEP's watch crystal never takes a shadow).\nA mesh names where \"Color by part\" deals it a palette colour in\n`userData.cadFillIndex` (otherwise meshes take the palette in traversal order),\nand a material with no source colour says so with `userData.cadSourceColor = false`.\n\n**The rule and its check.** Kit code names no file format, no file-kind switch\nand no STEP-assembly concept (topology, selectors, display records, explode,\nsection clipping), in code or in comments; only `view-settings/` may name the\nClip and Explode sections a view opts into, and `tools/model-effects/` the two effects\nit applies to whatever scene opts in. `npm run check:boundaries` runs\n`scripts/test/check-kit-boundaries.mjs`, which scans every kit source for imports\nof a renderer or of a file-family core module and for those words, against a\nshort allowlist that carries a reason per line and fails when an entry goes\nstale. The same script holds every split-out renderer to its slice\n(`RENDERER_SLICES`): a renderer imports the kit, `renderers/workspace` (the\nbackend connection a file is prepared against, the host's viewer preferences and\ncommand types, and `useWorkspaceDocument`), shared UI and core, and never another\nrenderer. It holds the host's file viewer (`src/file-viewer`) to the mirror of\nthat rule: the file viewer mounts a renderer through the registry and imports\nnone itself, so no host pays for a family it is not showing. The\nunbound-identifier test (`src/renderers/unboundIdentifiers.test.js`) scans the\nwhole renderer tree, so a renamed or added slice is covered the moment it\nexists. The same script's `checkSharedSceneBuilders` holds the rule of the next\nsection: each family's builder, loader, opening pose and look are imported by the\nrenderer AND by the snapshot's `common/headlessScene.js` from one core module, are\ndefined nowhere else, and no module on the snapshot's render path imports a mesh\nflattener.\n\n### One scene builder per family: the viewer and the snapshot CLI\n\n`cadgen snapshot` of a GLB, an STL, a 3MF or a robot description draws the scene the\nviewer draws, because both call the same builder. A family's scene construction lives in\n`@text-to-cad/core` (no React, no DOM beyond three.js); its renderer here keeps the loading\nstate, the tools and the panels, and the snapshot page (`common/headlessScene.js`, routed\nfrom `common/headlessRenderEntry.js`) keeps only what a still has and a viewport does\nnot: the camera fit, the studio set up per output, and PNG encoding.\n\n| family | builder (core) | viewer caller | snapshot caller |\n| --- | --- | --- | --- |\n| GLB | `lib/render/glbScene.js` `createGlbScene`, over `loadRenderGlbDocument` | `glb/useGlbScene.js` | `GLB_FAMILY` |\n| STL, 3MF | `lib/render/meshScene.js` `buildMeshScene`, over `loadRenderMeshByUrl` | `mesh/useMeshScene.js` | `MESH_FAMILY` |\n| URDF, SRDF, SDF | `lib/urdf/robotScene.js` `createRobotScene` over `robotParts.js`, loaded by `lib/urdf/loadRobot.js` | `robot/RobotRenderer.jsx`, `robot/useRobotDocument.js` | `ROBOT_FAMILY` |\n| STEP | `common/cadScene.js` `buildModel` | `step/scene/stepScene.js` | `headlessRenderEntry.js` |\n\nBoth hosts dress a scene the same way: the look from `resolveSceneSurfaceLook`, shadows\nwhile the lighting is on, `keepsAuthoredFinish` for Inspect's reflection environment, and\nthe ground sized from `restBounds`. Both cut and explode it the same way: Clip through\ncore's `lib/viewer/sceneClip.js` (the plane in the box the camera frames, `at` in the\nfile's unit, the cut capped as a STEP's is), Explode through the scene's `setExplode`\n(`lib/viewer/sceneExplode.js`, a STEP's layout over the file's tree). A robot's parts a\nQuick Edit's view hid come back in its job's `selection.hide`. A robot opens at `robotOpeningPose`\n(`lib/urdf/motion.js`: every joint's default, then an SRDF's `home` state) and a\nsnapshot's `--joint-values` go on top through the same joint matrices. What a snapshot\ncannot express, it refuses rather than approximates: a GLB's clips play in the viewer's\nAnimation section and have no snapshot flag. The pieces are pinned by `common/headlessScene.test.js`\n(core), `kit/view-settings/renderState.test.js` (one look from either route) and\n`tests/python/packages/cadgen/test_snapshot_family_scenes.py` (real snapshots, read as\npixels).\n\n### Shell\n\n`kit/shell` is what a file-family renderer needs from its host that is not about\nits scene. A renderer loads its document, builds its scene (`kit/scene.js`) and\ncalls one hook; the shell owns the rest.\n\n| module | what it is |\n| --- | --- |\n| `useRendererShell.js` | The hook. Per-file state through the host, the Display settings store and the content of Display's dropdown (`shell.display`), tool modes (Pencil and Shapes are the strip tools the shell itself owns, `shell.tools.drawing`; Display, Preview and its controls are the frame's; `toolModes` is omitted altogether by a renderer with no tools of its own), the file sidebar's and tool stack's layout (a viewer preference: `services.preferences.toolStack` — each sidebar section's size, the resizable panels' sizes, the folded stack panels — changed by one patch per gesture, `frame.changeToolStack`), prompt snapshots (the host's `captureRequest`), the clipboard screenshot (the drawing tools' Copy), what Quick Edit takes from the view (its file, how a copied prompt spells a path, the view as a picture, the viewer's Escape), the one Preview state (`shell.previewing` / `setPreviewing`, from `usePreviewState`; every way in and out goes through `setPreviewing`, which mounts Preview's view or takes it away and touches nothing of the editor's), Preview's scene (`frame.previewDesiredScene`: `previewViewScene(store.renderScene())`, Render over the file's settings as Preview draws them) and its viewport (`frame.previewViewRef`), Preview's Orbit (`shell.orbiting`, transient, off at every entry and exit), Render's studio code and environment prepared 600 ms after the model is on screen at Preview's quality, into the GPU device's cache Preview draws from (`previewPrepare`; it changes nothing the editor draws; not on a software renderer, the viewport's `softwareRendering`, where Preview prepares as it is entered), and Autoplay (`shell.autoplay`), alerts, shortcuts, and the live command surface (`setCamera`, `capture` and `resetCamera` act on the view on screen: Preview's while it is open). It owns no zoom control: the shell has none. |\n| `RendererShell.jsx` | The frame: viewport box; one centred column at the top centre (`TOOLBAR_POSITION`, `data-cad-tool-groups`) holding the tool strip (the renderer's tools; the view's controls — Display, the active mode's sphere, whose dropdown is `DisplayPopover`, the modes and the Grid switch, then Preview, a play triangle, for a `previewable` view — are the navbar's, portalled into `view.navbarSlot`, and end the strip only where there is no navbar), under it the tool in hand's sub-toolbar (the drawing tools' `AnnotationToolbar` while Pencil or Shapes is up, else the renderer's `subToolbar`: Explode's, Clip's), the update status, and the tool stack (`ToolStack`: the renderer's `toolPanels`; the column stops above the cube's line, `VIEWPORT_STACK_BOTTOM`); the file sidebar at the top-left (`FileSidebar` over the renderer's `sidebar`, `SIDEBAR_POSITION`: its tiles 8px in from the top and left, the open section's panel under them down to 8px off the bottom; on a phone one tile at the left of the strip's line, the strip padded clear of it, `TOOLBAR_POSITION_PHONE_SIDEBAR`, and one tabbed panel down to Quick Edit's peek, `SIDEBAR_POSITION_PHONE`); Quick Edit at the top-right; the view cube in the bottom-right corner; Preview's controls (Preview settings and Exit preview in the navbar slot, or, with no navbar, at the view's top-right, `PreviewChrome`'s `end`; the settings, a toggle whose panel docks at the view's top-right, `panel` (`PreviewSettings.jsx`: Orbit and its speed, then Preview's own settings, `frame.previewSettings`), then Exit preview, an X — and the file sidebar's Position and Animation alone, bare, at the top-left, `PREVIEW_SECTIONS`; nothing under the model); Preview's view (`PreviewView.jsx`, `data-preview-view`), mounted over the editor's viewport box while `shell.previewing` and unmounted as it closes, the editor's viewport `suspended` under it; the cover that answers the press (\"Preparing preview…\", `data-preview-cover`) until Preview's view has presented its first frame (`onPresented`), no finer detail is on its way (quality neither preview nor refining) and its picture has converged (`pictureProgress`) — lifting at once on a view the shell set moving (Orbit, a routine playing), never on a picture that detail started again (`previewCover.js`); at five seconds it stops waiting for detail and lifts on the picture converged, a second later at the latest, with no cover on the way out; the Animation section after the renderer's sidebar sections, for a file with routines, and the file's chosen Speed and Loop applied to its routine; the loading overlay; and the alert card. While the model loads, and once a load has failed in a way the model did not survive (an alert that cannot be put away), it draws none of its controls: only the load, or the card saying why, with the update status at the top centre in the strip's row; in a compact host (`appearance.compact`), none at all: the model alone. Nothing sits at the bottom centre. It takes `studio`, the renderer's own Preview while it is open: `{ scene, overlay(viewport, drawn), animation, runtimeLifecycle, onCameraSettled }` — the scene Preview draws, built by the family's own builder over the editor's geometry, what drives it from inside Preview's viewport (handed the Render scene it draws), Preview's routine (the Animation section's in Preview; Autoplay starts it on entry), and Preview viewport's lifecycle and settle. It takes `references` (what is selected, in the prompt grammar: what Quick Edit attaches and counts), `referenceChips` (how Quick Edit shows them) and `copySelection` (what ⌘C / Ctrl+C copies while the renderer's own tool is up and something is selected; null when nothing is), and, under Select, `onSelectPress`, `onSelectHover` and `selectsAnnotations` (whether Select's filter picks the annotations), and `inkTouches` (what the model is under a point of the view, `(clientX, clientY) => [{ part, ref? }]`: what the drawing tools record a mark as touching). One DOM structure (`data-slot=\"cad-file-view\"`, `data-cad-surface`, `data-cad-scene-backdrop`, `data-cad-toolbar`, `data-view-controls`, `data-cad-sub-toolbar`, `data-cad-file-sidebar`, `data-cad-tool-stack`, `data-tool-panel`) for every renderer. `frameProvider` wraps the WHOLE frame in the renderer's own context — the sidebar and the tool stack as well as the viewport, because they read it — and `onCanvasPointerDown` is a press that landed on the model, for a renderer with something to put down when the person reaches for it. The frame focuses itself on such a press either way. |\n| `ShellViewport.jsx` | The three.js viewport around ONE kit scene: `useViewerRuntime`, `useViewportCamera`, the look (rig or studio, environment, background, floor, grid, axes), the drawing tools' surface and the annotations' ink in the model, the view cube (bottom-right, `VIEWPORT_CUBE_BOTTOM`; not mounted in Preview's viewport, while the model loads, or when the shell says so, `viewCube`), frame presentation and the queued view-settings handshake. Its children may be a function of the viewport (`{ runtimeRef, hostRef, mountRef, viewerReadyTick, commitScene, syncSceneBounds }`), which is how a renderer mounts its own overlay or pointer pick. A scene that changes IN PLACE (it arrives in pieces, swaps its detail, is rebuilt under one identity) calls `commitScene()` from its own effect: the viewport re-reads what it placed, fits the stage and the depth range and applies the framing rules THEN, because a child's effects run before the viewport's own adoption effect. The one thing a commit never does ahead of the viewport is FRAME under a camera that is about to change: when the same render also changed the lens, the projection or the viewing mode, the stage is adopted at once and the framing follows once the camera has been given those props (a stored camera applied under the old projection and then converted comes out about a sixth smaller). A scene is framed once, on its `restBounds`; a STEP package's is the box its `assembly.json` declares (`bbox`), final from the first publish. A scene that says `complete: false` (a package that declares no box) is framed on what has arrived and once more when it is whole, unless the camera on screen is by then the person's: one they dragged, wheeled, turned by keys or the cube, zoomed to a selection or set through `setCamera` during the current mount, or one a reload restored from the file's view. A rebuild of the open file never re-frames; only Zoom to fit does. A file opened again after the tab left it starts with fresh framing, since leaving it dropped its view. `studio` makes it Preview's viewport (opened at rest, framed afresh from the default direction, keeping no camera, no cube and no drawing tools; `orbit` turns the camera at `orbitSpeed`); `suspended` makes it draw nothing while another view covers it, and draws a frame asked of it meanwhile synchronously, before paint, as it is uncovered (`runtime.setSuspended`). `preserveInteractionPixelRatio` keeps the idle pixel ratio while the camera moves (a scene drawn with hairlines, and Preview's orbit), and `runtimeLifecycle` (`onRelease(runtime, { handoff })` while the WebGL renderer is still alive, `onContextLost()`, `onInitializationError(error)`) is for a renderer that hangs its own objects or in-flight work on the runtime. `syncSceneBounds()` re-fits lighting, shadows and the floor's height to a scene that moved its own bounds, with no React render and no reframe; a sync that finds the bounds (and every setting the fit reads) as they were refits nothing and keeps the shadow maps (`look/stageFollow.js`). What is SIZED stays sized from the rest placement, in Inspect and in Render alike: the grid and stage (`sceneRadiusForBounds` on `restBounds`) and the Render studio's floor plane (`applyPhotographicStudio`'s `groundBounds`), so a pose or a playing routine never rescales or slides the ground under the model; `zoomToBounds(bounds)` frames part of the scene. Read-only test seams, the view on screen's (Preview's while it is open, the editor's again as it closes): `window.__cadCamera()` (the live camera, its depth range included) and `window.__cadStage()` (the ground's radius, the bounds the stage is fitted to, the floor's height, the studio floor's size and centre). |\n| `PreviewView.jsx` | Preview's view: a `ShellViewport` of its own (`preview`, `orbit`, `orbitSpeed`), with its own view-update coordinator over `frame.previewDesiredScene`, drawing the renderer's `studio.scene` and mounting `studio.overlay(viewport, drawn)` inside it. Its canvas, scene, camera and look are its own; only the GPU device is shared with the editor's, which it never reaches. `onPresented` says when its first frame is on screen. |\n| `usePreviewScene.js` | Preview's scene for a family whose core scene has `createSibling()` (GLB, mesh, robot): the editor's scene's sibling, made as Preview opens and disposed as it closes, before the scene it shares geometry with. |\n| `fileView.js` | The file's view `{ version, camera, display, playback, renderer }`, read forgivingly and written exactly: the camera (restored in place of the open-time fit; null fits), the Display settings, the playback settings (`tools/playbar/playbackPreferences.js`), and the renderer's own slices, each `{ signature, value }` and restored only under the signature the renderer declares now (`readFileView(raw, signatures)`); the camera, the display and the playback are always kept. The host keys it `[file path, renderer id]` in the tab store, which keeps it for the file on screen alone. Not in it: the tool in hand, a selection, measurements, ink, Preview, a routine's time — every open starts those afresh. |\n| `liveBinding.ts` | `attachLiveBinding`: the live command surface. Base commands (`readState`, `setCamera`, `resetCamera`, `setDisplaySettings`, `capture`, `thumbnail`) mean the same for every renderer; a renderer ADDS commands by name and DECLINES the known host commands (`HOST_LIVE_COMMANDS`) that make no sense for it with the sentence the caller reads. Binding fails when a renderer does neither. |\n| `promptContext.js` | `createViewPromptContext` (a snapshot and what it depicts) and `promptDeliveryError`. A renderer with a reference vocabulary of its own passes `promptContext` instead, and may then return that vocabulary from `promptReferences`. |\n| `loadReport.js` | `shellLoadReport`: what the shell asks the status kit about one document load, as one pure function, and all it returns is the loading presentation over the viewport. A renderer whose document is a plain download passes `load` and nothing more. One whose document can be EDITED or PREVIEWED while it is open knows more than the shell can: `load.editPending` (queued work of the person's own, with nothing of it on screen), `load.currentPreview` (that work IS what is drawn, so the wait is over even though the write is not) and `load.finding` (the file is not even located yet). An alert is `load.alert`, which the frame draws as the alert card (`ViewerAlertCard`: every alert; one the model survives, a warning beside it included, can be dismissed). |\n| `useViewerShortcuts.js` | Which mounted viewer an Escape or a ⌘C / Ctrl+C belongs to (focus inside it, or the page after a press inside it); the renderer says what Escape means, and a copy is the active tool's (the drawing tools' view with its annotations, else the selection's references, `copySelection`). The order is [the design system's](settings-ui.md#keyboard). |\n| `viewportLayout.js` | The chrome's geometry: its inset from the viewer's edges (`VIEWPORT_INSET_PX`, 8px), the strip's row (`VIEWPORT_TOP_BAR_PX`, 34px), the cube's size and place (bottom-right, 2px from the right, `VIEWPORT_CORNER_INSET_PX`, and `VIEWPORT_CUBE_BOTTOM`, 8px off the bottom so its axes stay inside the view, plus `--cad-viewport-corner-bottom`, which a host whose control floats over that corner sets to lift it clear), the tool column's bottom (`VIEWPORT_STACK_BOTTOM`: an inset above the cube's line). |\n| `ViewportContextMenu.jsx` | The viewport's menu on a secondary TAP (a secondary drag pans; primary and secondary together is the pan chord). The gesture, the anchor, the clamping and the dismissal are the shell's; the ITEMS are the renderer's (`contextMenuItems(press)`), asked at the moment of the press, and `onContextMenuOpenChange(open)` says while the menu is up. A renderer that passes no items has no viewport menu at all — every renderer but STEP. |\n\n```jsx\nconst shell = useRendererShell({\n  view,                 // RendererViewProps, unchanged\n  services,             // { preferences, onPreferenceChange, live?, captureRequest?, acknowledgeCommand? }\n  resource, modelKey, revisionKey,\n  features,             // ViewFeatures: the Display sections this family opts into\n  toolModes,            // createToolModes({ defaultMode, modes }), or omitted: no tools of its own\n  previewable,          // true for a 3D view: it offers Preview; omitted (a 2D view): no Preview, and a request keeps the normal view\n  scene,                // KitScene | null\n  load,                 // { busy, updating?, progress?, alert? }: the renderer's document load\n  animation,            // routine runtime with its own `clock` (and `onRelease`), or null\n  live,                 // { commands?, declined?, state? }\n  // optional: promptReferences, promptContext, escape, sceneScaleMode, preview, tool,\n  //   viewSettings, viewerRef, onCameraSettled, onRuntimeAlert, presentationReport,\n  //   preserveInteractionPixelRatio, runtimeLifecycle,\n  //   rendererState   { signatures, read }: the renderer's slices of the file's view, read when it is written,\n  //                   each restored (by the renderer, `readFileView(view.state, signatures)`) under the same signature\n});\n// Renderer-facing, beside `tools`, `display`, `toolMode`, `selectTool`, `previewing`, `requestRender`:\nshell.requestFrame();            // a frame that keeps the shadow maps: a highlight moves and reshapes no caster\nshell.syncSceneBounds();         // the scene moved its bounds (a pose): the stage follows, no render\nshell.scheduleStateSave();       // a slice changed: write the view soon, and on unmount\nshell.playback; shell.setPlayback(patch); // the file's: Orbit's speed, Autoplay, the chosen Speed and Loop\nshell.orbiting; shell.setOrbiting(on);   // Preview's Orbit, transient: off at every entry and exit\n// The shell adds Display and Preview (a `previewable` view's, in the navbar), Preview's controls (Preview\n// settings, Exit preview), the Animation section for a file with routines, and the sub-toolbar of its own tool (the drawing tools').\nconst tools = [shell.tools.own({ id, label, icon, menu }), shell.tools.annotate].filter(Boolean); // []: no tools, and no strip\n// subToolbar: the renderer's tool in hand's row; sidebar: the file's sections, each a ToolPanel, whatever the tool\nreturn <RendererShell shell={shell} tools={tools} subToolbar={toolRow} sidebar={<>{treeSection}{positionSection}</>}\n  viewportOverlay={viewport => <PointerPick viewport={viewport} scene={scene} enabled={selecting} onPick={pick} onHover={hover} />} />;\n```\n\nA CAD renderer's controls are the shell's chrome: its `tools` on the strip, the tool in\nhand's `subToolbar` (`kit/tools/SubToolbar.jsx`) under it, and its `sidebar`, the file's\nsections down the left edge (`kit/tools/FileSidebar.jsx`), each a `ToolPanel` drawn as a\nsection, whatever the tool: a STEP assembly's Model tree and Position, a robot's Links, an\nSDF's metadata and Position. Sections are their content's height and the bar scrolls as one,\nup to all the room down the left edge or the cap a person set from its bottom-right grip\n(`kit/tools/ResizeGrip.jsx`, Quick Edit's), kept under `sidebar` in the tool stack's layout;\na section folds where there are several, kept by `id`. A tree has no X and is on screen under\nevery tool (out of sight only in Preview, kept mounted with its state); a pick scrolls its row\ninto view there. A renderer may also hand `toolPanels`, the tool stack under the strip and\nsub-toolbar, each panel its content's height, with `fit` saying how it gives way on a short\nviewer: `\"tree\"` first (to 128px or its content), then `\"details\"` together, each in\nproportion to its height (to 96px or its content), never `\"fixed\"`; the column scrolls only\nif the rest still does not fit. Every panel is `TOOL_PANEL_WIDTH` wide (164px, a six-tool\nstrip's); `resizable` makes a panel the person's to size on its own, from the grip in its\nbottom-right corner alone, kept under its `id`. STEP and the robot renderer hand none.\n\nA renderer whose model moves outside React (a robot's pose) keeps that state in\nits own store: `rendererState.read()` is called at the moment the view is\nwritten, so the last write before unmount is saved; `shell.scheduleStateSave()`\nsays it changed; `shell.syncSceneBounds()` carries the moved bounds to the stage.\nNone of them renders a component.\n\nThe workspace half of a renderer's surface is one hook too\n(`renderers/workspace/useWorkspaceDocument.js`): `useWorkspaceDocument({ view, data })`\nreturns the live catalog entry of the prepared file, the prompt `resource`, the\n`services` object the shell takes and the host's commands;\n`workspaceLoadAlert` turns a catalog or loader failure into `load.alert`; and\n`useDeclinedSelectReference` consumes a host's request to select a reference in\na file that has none without changing the view. A renderer is then its\nscene hook, its tool list, its tools' rows and its sidebar sections, if it has any\n(`glb/GlbRenderer.jsx`, `mesh/MeshRenderer.jsx`, `robot/RobotRenderer.jsx`). The DXF renderer uses the workspace module without the\nshell: it has no scene, so it reaches the same catalog entry and commands directly.\n\nThe shell's behaviour has one real-browser test,\n`kit/shell/RendererShell.browser.test.mjs` (deferred files, warm reopen, isolated\nper-pane state, Preview's camera, gated Display sections, settings that never\nreplace the canvas), and the drawing tools have one scenario (`harness/drawScenario.mjs`)\nrun under the shell (`kit/tools/annotate/Annotate.browser.test.mjs`).\n\nThe STEP renderer is on the shell end to end: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`,\nwhose frame, file view and host glue are the shell's. What STEP keeps of its own is\nwhat only a STEP has — its slices of the file's view (`workbench/stepViewSlices.js`,\nread and written by `workbench/useStepView.js`: the tree's expansion, hidden parts and\nisolated assemblies, the pose, the large-file setting, each behind the signature that says\nwhether it still fits the file), its prompt context, and the resource its live state reports while a rebuild is held\nbehind the previous mesh (`live.resource`). The surface composes modules of its own around\nthe shell: Measure's session (`kit/tools/measure/useMeasureSession.js`, the kit's), the pose and routines\n(`workbench/useStepMotion.js`), the context menus as pure descriptors\n(`file-view/stepMenus.js`) and what a selection or a node copies (`file-view/stepCopy.js`).\nThe shell was widened for it rather than forked: it accepts display settings a renderer\nmade earlier than it could hand them back (`viewSettings`), a viewer handle the renderer\nowns (`viewerRef`), externally held tool state (`tool`) and preview state\n(`preview`, from `usePreviewState`: STEP's picking and recognition gates run before the shell's hook),\nand a load report that knows about finding, editing and previewing.\n\nIts safety net is `step/StepRenderer.browser.test.mjs`, which opens a real `.step`\nin a real browser: a two-part component-SURF package with one revolute mate, one\nnamed pose and one routine, committed under `step/__fixtures__/step/` and served by\n`harness/stepScenario.mjs` exactly as the backend serves one. The same two shapes\nare also served as a package that really ARRIVES IN PIECES\n(`stageProgressiveFixture`): twenty-five components held one batch at a time, so\nthe loader publishes at 8, at 24 and at 25 and the MIDDLE publish is an in-place\nchange with nothing else moving. That is the only shape of load that can show what\n`viewport.commitScene()` is for — two components publish once, and a package whose\nsecond publish is its last is re-adopted by the viewport for reasons of its own.\n`kit/tools/annotate/Annotate.browser.test.mjs` covers the drawing scenario under the shell,\nand each renderer's own browser test asserts that the STEP-only Display sections\nare absent from it. The shell surfaces only STEP uses so far (the viewport menu and\nits open report, the camera-settled report, a scene\nthat arrives in place, the pixel ratio kept for hairlines, the runtime lifecycle,\nand what a renderer says about a load that is more than a download) are driven\nthrough `renderers/shell-harness` in `RendererShell.browser.test.mjs`.\n\n## DXF renderer\n\n`createDxfRenderer` (`@text-to-cad/ui/renderers/dxf`, id `dxf`) shows a `.dxf`. The CAD\nrenderer does not match one.\n\nIt is **not on the shell**, and that is the whole design: a DXF is a finished 2D\ndocument, so the pane is a canvas and the drawing is painted on it. No three.js\nviewport, no scene, no Display settings, no Preview. The questions the old\nMaterial/Bends tabs answered were about a sheet-metal part the viewer was inventing\nfrom the file; a drawing is not that. What it has is what a person needs to talk about\na drawing — the kit's chrome, composed around the canvas rather than around\n`ShellViewport` (`DxfRenderer.jsx`): the strip at the top centre, the tool's row under\nit, the file sidebar, Quick Edit at the top-right.\n\n- **Tools** (`dxf/tools.js`): **Select** (in hand; the view's hand too: a drag pans, a\n  tap picks), **Pencil**, **Shapes** and **Measure**, no view controls (Display, Projection and\n  Preview are a 3D view's). Select picks, front to back, a measurement, then ink, then\n  the drawing's ENTITY under the pointer (`drawing2d/entities.js`'s `entityAt`: within\n  6 px, a fingertip's 16; the lines over a hatch before the hatch; nothing on a hidden\n  layer); Shift adds. Its selection is the kit's (`useSceneSelection`,\n  `useSceneReferences`) over a drawing \"scene\" (`createDrawingScene`), lit over the\n  drawing (`drawEntityHighlights`). Its cursors are the 3D views': the arrow, pressed\n  or dragging too, the pointing hand over what a press picks. The drawing tools are the kit's\n  layer (`AnnotationOverlay` with a flat view's `surface`, `dxf/drawingAnnotation.js`):\n  ink lies on the sheet in drawing coordinates (x, y, 0), on no part, painted with the\n  drawing (`drawing2d/marks.js`); its row has no Placement. Measure is the kit's session,\n  overlay and chips (`useMeasureSession`, `useMeasureOverlay`, `measurementChips`) over a\n  plan-view camera (`drawing2d/planView.js`'s `planProjection`); a pick snaps\n  (`snapAt`) to an end, a circle's or arc's centre (`center`, \"Centre\"), else a point on\n  a line, and reads in the unit `$INSUNITS` declares — \"units\", never millimetres, when\n  it declares none. With Measure in hand its row under the strip lists its measurements\n  (`MeasurementsBar`), none while nothing is measured.\n- **References**: an entity by its DXF handle, `drawing.dxf#h2F` (`drawingReferences.js`);\n  everything a block reference draws is its INSERT, a dimension is one entity. Quick\n  Edit's chip names it by type and layer (\"Circle on CUT\", \"Block M5_NUT on HARDWARE\"),\n  its card the handle, type, layer, block, centre and radius.\n- **Layers**, a file-sidebar section, only where the drawing has the structure to read\n  (`layersSectionShown`): four or more layers drawn on, or any block reference. Each\n  layer's pen, name, entity count and eye (hidden: not drawn, picked or snapped to), and\n  under it the block references placed on it, a click picking one.\n- **Quick Edit** carries the picks and the ink (never the measurements: they are the\n  person's); its views are the window on screen as a plan-view camera (`planCamera`: looking\n  straight down, `up` +Y, `orthographicHalfHeight` the window's half height), which `cadgen dxf\n  snapshot --job` frames again, drawing the job's annotation over the drawing.\n\n- **The picture comes from the BACKEND.** `client.drawing(file)` is\n  `GET /__cad/drawing` (`apps/web/docs/backend.md`), asked again while the server\n  renders a drawing it has not drawn before: ezdxf flattens the modelspace on the\n  server — text placed, dimensions exploded, hatches filled, blocks placed — and the\n  client receives five primitive shapes and `text` in DXF coordinates, y up. **This\n  renderer never parses DXF.** The payload is cached server-side by the document's\n  content hash, so reopening a file costs a round trip and nothing else.\n- **Drawing is core's** (`@text-to-cad/core/lib/drawing2d`), so the headless snapshot\n  bundle paints the same picture from the same payload: `fitTransform` / `zoomTransform`\n  / `panTransform` (one uniform scale and a translation; the y flip lives in the\n  transform, not in the geometry), `prepareDrawing(payload)` (paths built ONCE, per\n  colour for strokes and per primitive for fills; text kept as strings) and\n  `drawDrawing(ctx, drawable, …)` (text set with `fillText`, stretched to the width the\n  server measured, so the page's face keeps the drawing's layout).\n  That bundle IS `cadgen dxf snapshot`: cadgen resolves a `.dxf` to the same\n  `cadgen.drawing_payload` this route answers with and the page fits and paints it\n  (`common/headlessDrawingRender.js`), so a CLI render cannot show what this pane\n  cannot. The CLI's option surface was cut to match — no display, no render mode,\n  `--appearance light|dark` and a size; a job may add a plan-view `camera` and an\n  `annotation` on the sheet, what a Quick Edit's views carry.\n- **Hairlines, always.** Strokes are 1.25 CSS px at every zoom, as AutoCAD draws with\n  LWDISPLAY off: paths are in MODEL space and the context carries the view, so one\n  `lineWidth = 1.25 / scale` per frame rebuilds no geometry. Model-space lineweights\n  are not displayed at all.\n- **`color: null` is the default pen** (ACI 7, \"whatever contrasts with the\n  background\"), resolved at DRAW time against the app's `--foreground` on its\n  `--background` — the same pair the 3D viewers' chrome uses. One payload therefore\n  serves both themes: flipping `.dark` repaints, it does not refetch. The tokens are\n  read off the pane, and a mutation on `<html>` schedules a frame. When they cannot be\n  read — and in the snapshot bundle, which has no stylesheet at all — the pair comes\n  from `@text-to-cad/core/lib/appTheme.js`, the one place both colours are written down.\n- **Fills are even-odd, and each one is filled on its own.** A `filled-paths`\n  primitive's inner rings are its holes; merging two overlapping regions of one colour\n  into a single path would turn their overlap into a hole as well. Fills go down\n  before strokes, which is the one ordering that never hides an edge.\n- **Interaction** (`dxf/useDrawingView.js`): fit on open and on resize until the\n  person moves the view, drag to pan (a primary press, one finger, once it has travelled\n  more than a tap's 4 px; a press that lets go before is the tool's tap), wheel or pinch to\n  zoom about the pointer, double-click to fit again. The view lives in a REF and the\n  canvas repaints through one `requestAnimationFrame` when something changed; a pan\n  never re-renders the component tree. A resize paints at once, inside the pane's\n  ResizeObserver (after layout, before paint): resizing the canvas wipes it, and a frame\n  asked for there would leave the pane empty for one frame\n  (`dxf/DxfResize.browser.test.mjs`). The backing store is DPR-aware\n  (`kit/viewport/pixelRatio.js`). What the tools draw (highlights, ink) is painted in\n  the same frame, after the drawing, so a capture holds it.\n- **Navbar**: nothing of the drawing's — no zoom buttons, because zooming a drawing is\n  the pointer's: wheel or pinch about it, drag to pan, double-click to fit. The host's\n  `captureRequest` is the canvas as a PNG, background included, delivered through\n  `host.promptContext`.\n- **Empty and failed drawings.** `bounds: null` (nothing in the modelspace) is a quiet\n  sentence over the empty pane, not an error. A non-200 becomes the ordinary actionable\n  alert card (`ViewerAlertCard`) carrying the SERVER's sentence (`failureAlert`,\n  `kind: \"http\"`); a payload from\n  a cadgen that disagrees about `schemaVersion` gets its own alert whose recovery is to\n  update cadgen and the app together.\n- **Host commands**: `resetCamera` fits the drawing again, `capture` hands over the\n  PNG, `clearSelection` lets go of the picks, and `readState` reports `camera: null`,\n  an empty `display`, the selection as references \n\n<!-- [TRUNCATED AT 50KB] -->","packages/ui/docs/feature-detection.md":"# Client-side STEP feature detection\n\nFeature detection helps people navigate and select geometry in the shared Model\ntree. It belongs to the STEP renderer in `@text-to-cad/ui`; web and desktop run the\nsame code. It reads saved geometry through the injected CAD resource service.\nIt never reads model source, writes artifacts or executes a CAD kernel.\n\nKeep recognition independent of cadgen compilation, Python inspection and the\nreference grammar. A recognized feature is a group of existing canonical faces\nand edges, not a new kind of reference. Selecting or copying it uses the normal\noccurrence-scoped reference and prompt-delivery flow. Feature labels and inferred\noperations are not stable identities across document edits or recovered source\nhistory. Recognition is optional: structural rows, rendering and part selection\nmust work without it.\n\n## Demand and cancellation\n\n1. Load the lightweight assembly descriptor to display the hierarchy.\n2. The Model tree requests recognition for expanded, visible parts. Collapsed\n   parts need no recognition or exact-surface request just to show their rows.\n3. Resolve each requested occurrence to its component. Repeated instances share\n   component analysis while retaining distinct selection IDs and placement.\n4. Reuse completed recognition metadata when its exact geometry identity matches.\n   Otherwise obtain the component's SURF data through the resource provider and\n   analyze it in a disposable worker.\n5. Publish the result, cache successful metadata, then process the next requested\n   component. Each mounted inspector owns at most one active recognition worker.\n\nChanging expansion updates the queue without restarting an active component that\nis still requested. Switching between occurrences of that same component also\nkeeps its work. Removing the last requested occurrence cancels both pending\nsurface loading and worker work; queued components that are no longer requested\nare skipped. Taking the model tree off screen (choosing another tool, or entering\npreview), changing geometry or resource scope, and\nunmounting cancel the entire inspector's pending work. Cancelled jobs cannot\npublish late results or populate the completed cache.\n\nThis is deliberately a sequential queue per inspector, not a global worker pool.\nCompleted results are reusable between mounts; pending jobs are not shared\nbetween separate inspectors. Retry clears failed results and queues those\ncomponents again without cancelling other components that are still needed.\n\nLoading is lazy **per component**: expanding a part analyzes that component as a\nwhole, not only the feature rows subsequently opened. Recognition neither expands\nother parts nor blocks the initial structural tree. The stable top row counts\npresented top-level features; it does not display global recognition progress.\nSelection, isolation and reveal rules remain in the\n[Model tree contract](cad-renderer.md#step-panels).\n\n## Cache identity and lifetime\n\nThe completed cache key combines:\n\n- The resource provider's workspace/generation scope.\n- `MODELING_RECOGNITION_VERSION`.\n- Exact surface input and object identities, or a provider-scoped immutable SURF\n  URL for older static packages.\n\nDo not key recognition by filename, display label or assembly occurrence. On a\nwarm reopen, accepted component identities can avoid surface requests altogether,\nbut only after workspace, provider generation, entry revision and runtime descriptor\nmatch. Changed geometry or scope must never reuse the previous binding.\n\nThe least-recently-used cache holds at most **512 components / 8 MiB** of accounted\nserialized metadata. Each read returns a private copy. It retains no SURF buffers,\nworkers, scene objects, pending promises or failed results. The active inspector\nalso retains its own results, including errors for explicit retry; disposing it\nreleases that local state. Large or evicted results may require recomputation.\n\nThis cache is **in memory**, separate from cadgen's persistent surface/mesh store\nand host preference storage. Refreshing the page or restarting the app loses it.\nThere is no recognition sidecar, disk database, compile step or backend endpoint.\n\nAdvance `MODELING_RECOGNITION_VERSION` in\n[modelingRecognitionCache.js](../src/renderers/step/workbench/modelingRecognitionCache.js)\nwhen inference rules, tolerances or serialized results change, including returned\nlabels, measurements and face/edge grouping. This is an internal algorithm\nrevision, unrelated to the repository's release version. Presentation-only\nchanges outside cached results do not require a bump. Geometry and resource\nrevisions remain independent parts of the key; the algorithm version replaces\nneither. If configurable recognition options are introduced, their semantics\nmust also participate in the key.\n\n## What the recognizer infers\n\nThe current algorithm first tries a complete extrusion, revolution or supported\nsolid reconstruction. Otherwise it presents an imported body with local feature\ncandidates and remaining geometry. That precedence is a presentation heuristic:\nthe same circular cut can appear as a cut extrusion or a bore in different\nbodies. Changing these classifications is separate from cache/lifetime work.\n\n- **Extrusions:** paired analytic cap contours, straight walls, side area and\n  solid volume support a possible base/cut sequence. Profiles select real STEP\n  boundary edges.\n- **Revolutions:** full coaxial cylinder/cone solids can yield a closed axial\n  profile and 360-degree revolution checked against solid volume.\n- **Pockets and bosses:** local planar caps, translated rims and straight walls\n  identify supported prismatic regions. Current local tests cover convex analytic\n  profiles; they do not cover every pocket or boss.\n- **Bores:** adjacent cylindrical patches are joined across export seams.\n  Annular shoulders and full conical transitions can connect stages into blind,\n  countersunk or counterbore candidates. Boundary samples and enclosed face\n  bounds reject evident intrusions; they do not prove an empty volume.\n- **Blends and other supported solids:** connected constant-radius\n  cylindrical/toroidal blends, rounded boxes and supported ruled lofts have\n  analytic recognition paths. Single curved corners are not automatically\n  separate fillet operations. Shells and patterns are not invented.\n\nUnclaimed faces remain available as other geometry. Candidate ordering is not a\nmodeling timeline, and a missing candidate does not establish that a feature is\nabsent. Measurements describe supported geometry; recognition is not engineering\nvalidation. Independent kernel reconstruction checks belong to development tests,\nnot the runtime viewer or Python inspection API. Complete reconstruction claims\nrequire both directional Boolean differences to agree; finding a local candidate\nor matching its tool volume alone does not establish a rebuildable whole part.\n\n## Limits and failures\n\nThe worker ticket permits at most 16 MiB of SURF data. Analysis rejects inputs\nover 6,000 faces or 20,000 edges; the worker/ticket stage has a 10-second timeout.\nThese are safety limits, not promised execution times. Surface derivation before\nworker startup has its own resource-service lifecycle.\n\nUnavailable data, unsupported-size inputs, worker errors and timeouts produce a\ncomponent error and explicit retry. They do not become reusable cached results.\nOther requested components can still be processed. Existing ordinary geometry\nand part selection remain available. Keep workers off the UI thread, preserve\nthese bounds, and profile large parts before adding persistent caching or more\nconcurrency.\n\n## Implementation and verification\n\n- [useModelingRecognition](../src/renderers/step/workbench/useModelingRecognition.js):\n  descriptor demand, per-inspector queue, resource requests and cancellation.\n- [modelingTree.worker](../src/renderers/step/workbench/modelingTree.worker.js):\n  resource-ticket loading, SURF parsing and inference.\n- [modelingTree](../src/renderers/step/workbench/modelingTree.js) and adjacent\n  `modeling*` helpers: pure inference and canonical reference mapping.\n- [modelingPresentation](../src/renderers/step/workbench/modelingPresentation.js) and\n  [ModelingTree](../src/renderers/step/components/workbench/ModelingTree.jsx):\n  occurrence binding, presentation, expansion and selection.\n\nKeep renderer-private pure helpers here; they do not need to move to core merely\nbecause they do not import React. Hosts implement resource effects through the\n[viewer host contract](viewer-host.md), never a second recognizer.\n\nRun `npm --prefix packages/ui test` from the repo root for helper and React\ncoverage. Recognition tests cover real STEP-derived fixtures, version/geometry\ninvalidation, cache bounds and warm reopening, expansion during pending work,\nrepeated instances, retry, disposal and rejected late results. Fixture provenance\nlives in the [fixture guide](../src/renderers/step/workbench/__tests__/fixtures/README.md).\nAdd small test-owned regression fixtures for changed inference rules. Rebuild\npackage exports and the bundled viewer before testing either app.\n","packages/ui/docs/file-viewer.md":"# FileViewer\n\n`@text-to-cad/ui/file-viewer` exports the complete file view: one file by its\nabsolute path, under the navbar, with the explorer its name opens, renderer loading,\nand the loading and error pages.\nIt imports no concrete renderer. Applications compose registrations from the\nseparate `@text-to-cad/ui/renderers/*` entry points and their own\n`defineFileRenderer` definitions ([renderers](renderers.md)). A CAD host composes\n`CadViewer` (`@text-to-cad/ui/cad-viewer`), which is this component with the five\nCAD renderers, the home and the standard error pages ([viewer host](viewer-host.md)).\n\n```tsx\n<FileViewer\n  file={selectedPath}   // absolute; null is the host's home\n  host={viewerHost}\n  renderers={registeredRenderers}\n  state={viewState}\n  onStateChange={persistViewState}\n/>\n```\n\nSee [ViewerHost](viewer-host.md) for required host services and prompt delivery.\nCreate sources and registration arrays at their owning workspace or tab\nlifetime. Changing their object identity cancels outstanding document work.\n`source.id` is a stable identity, independent of a server's temporary port. Every\npath is absolute and `/`-separated (`/a/b.step`, `C:/a/b.step`); containment and\nauthorization remain in the host service. The host controls navigation\n(`navigation.openFile(path)` where the view can show another file, and `home()` where it\nhas a home; a view that shows one file alone has neither, and no explorer) and keeps\n`FileViewerState` in its tab store (`@text-to-cad/ui/tab-store`; `useTabViewerState`\nderives it): `renderers`, under encoded `[file path, renderer ID]` keys, each the\nfile's view.\n\nA source reads files and never writes them. `stat(path)` answers a file's metadata;\nwhere its files can be browsed, `list(directory)` answers a folder's entries and\n`search(directory, query)` the files under a folder whose path below it holds the\nquery, saying when it stopped early. What the file menu offers is the host's\nseparate `fileActions` capabilities. Sources receive an `AbortSignal` for each read;\nnoncancellable reads still check the signal before returning. A renderer that\nacquires a resource (a URL lease, a worker) returns its release as the prepared\ndocument's `dispose`; FileViewer calls it on navigation, reload, cancellation and\nunmount.\n\nUse `defineFileRenderer<T>` to keep the prepared payload paired with its lazy\ncomponent. A definition supplies `id`, numeric `priority`, `matches`, `prepare`,\nand `load`, and optionally `fallback`. Larger priorities win.\nDuplicate IDs and equal-priority matches\nproduce explicit errors. At most one registration may declare `fallback: true`;\nit runs only when no ordinary registration matches. Registration construction\nand matching do not load the component.\n\n`prepare` returns `{ data, dispose? }`. FileViewer opens a file's document when the\nfile is shown, and again only on `reload`: a renderer follows its file's changes\nitself (every CAD renderer loads the next revision behind what is on screen). A CAD\nfile's controls are its own, over the viewport: its tool strip, the tool in hand's\nsub-toolbar, and its file sidebar\n([the design system](settings-ui.md#the-file-sidebar-and-the-tool-stack)); the view's\ncontrols (Display, Preview) are the navbar's, last at its right end.\n\nBelow 600px of its own width FileViewer is mobile (`useViewerMobile`). The width is a\nbreakpoint boolean, never a pixel value, so a resize within one layout does not\nre-render the renderer.\n\nA renderer is handed, besides its document: `onNavigationActionsChange`, for its\nnav-row actions (`FileNavigationAction`, with an optional shorter `hint`), hidden\nwhile it reports `onReady(false)`; `appearance`; and `onOpenFile(path)`, which shows another file, by\nits absolute path, in this view (a robot's mesh), absent where the view shows its file alone. Its loading and update status are its\nown to show: a CAD renderer shows them in its viewport. Host-specific pages are supplied through `presentation`,\nwithout duplicating the view's placement: `home` is a host's own page for a view with\nno file (every CAD host's model library), under the navbar; `loading`; and\n`error({ message, missing })` for a file that will not open, `missing` when it does\nnot exist. Per-file renderer state is also accepted during a departing renderer's\ncleanup, while it still belongs to the same source.\n\n## The navbar and the explorer\n\nThe navbar is drawn over every file from the moment one is asked for, and over the\nhost's home — only its logo and menu, without Back to files, Back and Forward where the\nhost keeps the view's history, and the host's update and Full size, where they stand over\na file — never for a `compact` view (a\npicture: the home's thumbnails); the CAD viewer's Preview keeps it. Over a file, left to right:\n\n- the text-to-cad \"C\" logo (named Menu, with no chevron), which opens the app menu\n  (`navigation/AppMenu.jsx`): Back to files where the host has a home\n  (`navigation.home`), the person's settings (`appSettings`), Send feedback, GitHub,\n  Discord and, in gray, the version and who made it. FileViewer builds it from\n  `host.links`, `appSettings` and `environment.platform`. A host with no home has the\n  logo and the menu too, without Back to files;\n- Back and Forward, where the host keeps the view's own history (`history`, a\n  `ViewerHistory`: the CAD app's views; a browser's page has its own and hands none): two\n  arrows, each there always and disabled where it has nowhere to go;\n- the open file's name: a button that opens the explorer where the source can both list\n  and search, and plain text otherwise;\n- the ⋯ file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host's `fileActions` can do it, and no ⋯ where it can do\n  none.\n\nThe logo, Back and Forward, the name (where it opens the explorer) and the ⋯ are one kind of control: transparent\nat rest, the same accent background while the pointer is on it and while its menu is open, a\npointer cursor, and no tooltip.\n\nAt the right, only what a host or a file adds: the host's update button, a renderer's\nactions and the host's Full size, where the host shows the view small. A renderer's\nown view controls (a 3D view's Display and Preview, or Preview's settings and Exit preview)\ngo last, in the box FileViewer hands it (`navbarSlot`, `data-view-controls`).\n\nThe explorer (`navigation/FolderExplorer.jsx`) is a popover under the file's name, a\nfixed 18rem wide and never resized. It floats over the view, so opening it resizes\nnothing, and it is built anew on each opening, so it starts in the open file's\nfolder, its filter taking the keyboard (on a phone the explorer holds focus itself, so no\nkeyboard comes up). It lists one folder at a time through `source.list`: the folder's\nsubfolders, then its CAD files, the open file highlighted, in compact rows (a folder's glyph\nits disclosure chevron). A press on a subfolder opens it inline, under itself and a little\nfurther in, reading it through `source.list` the first time; a double-click opens it in the\nexplorer's place. A breadcrumb shows as many of\nthe last three folders of the one it is in as fit whole, measured before it is painted:\na crumb is never cut, the farthest giving way first to a \"…\" button whose menu lists the\nfolders above the crumbs to jump to, and only the folder it is in, alone beside the \"…\",\nis cut to the row. A crumb, as a double-click does, opens a folder in place, and there is\nno up arrow. \"Filter files...\" searches every file nested under the folder it is\nin through `source.search`: the client waits 150 ms after the last keystroke and\naborts the search before it, and each match shows its path below the folder, the\nsubfolders muted before the name. An answer that is `truncated` adds \"Stopped early.\nKeep typing to narrow the search.\" The viewer server bounds both reads\n(`GET /__cad/folder`, `/__cad/search`): hidden and build folders are left out of each,\nand a search stops at 200 results, about 1.5 s or depth 24, walking a folder it reaches\ntwice once. A pick opens the file through `navigation.openFile` and closes the popover.\nUp and Down move between the filter and the rows; Escape closes it like any popover.\n\nThe browser harness beside FileViewer exercises injected renderers, source and file\nchanges, navigation actions, the explorer and the file menu, the mobile breakpoint,\nand resource disposal. Run it with the package test command after building the shared\npackages. Chromium must be available for the browser tests.\n","packages/ui/docs/lod.md":"# Level of detail, memory, and live updates\n\nWhat the viewport does in the background between \"a file was selected\" and\n\"the picture has settled\": which tessellation it starts from, how it refines,\nwhat it refuses under memory pressure, and how it follows a model that is\nbeing rebuilt while you watch.\n\nAll of it is the STEP package path of the STEP renderer. A triangle mesh (STL, 3MF) and a\nGLB have no levels: their renderers load the file whole, parse it once per revision (an\nSTL in a worker) and keep the decode cached, so reopening one fetches and parses nothing\n(see [Mesh renderer](cad-renderer.md#mesh-renderer)).\n\nThe observable promises are in [the web app README](../../../apps/web/README.md#the-laws-that-bind-the-app);\nthis file is the mechanism behind them. None of it may change **exact**\ngeometry, measurements or explicit mesh-export tolerances — detail is a\ndisplay concern only.\n\n| § | Covers |\n|---|---|\n| [1](#1-where-a-model-starts) | Coarse-first admission, cached standard meshes |\n| [2](#2-what-refinement-samples) | Camera sampling, offscreen components, hysteresis |\n| [3](#3-admission-and-memory-pressure) | Budgets, reservations, coarsening caps |\n| [4](#4-the-replacement-batch) | CID caps, the first-ready deadline, adoption receipts |\n| [5](#5-cancellation-failure-and-cleanup) | Parked targets, restoration, ownership |\n| [6](#6-following-an-active-build) | The edit feed, what the viewport shows, the status heartbeat |\n\n## 1. Where a model starts\n\nAssemblies with at least 64 unique components can start at a coarser display\ntessellation when standard meshes are not cached. Cached standard meshes are\npreferred immediately, subject to their probed decode size and admission. The\ntiers are probed a chunk of components at a time and the cached bodies read a\nbatch at a time (`createInitialDisplayPlans`, `packageBatchReads.js`), the first\nof each the size of the first publish, so the first geometry waits on no more\nthan it draws.\nSmaller assemblies start at the standard level, except that an individually\noversized component may start coarse. A component above the concurrent decode\ncap runs alone only when the shared Viewer memory envelope can reserve its\ncomplete estimate.\n\nCoarse geometry is a temporary preview: visible components automatically reach\nat least the standard level, preserving its angular smoothness even when\nprojected chord error alone would permit a coarser mesh. Close inspection can\nrequest finer detail, and so does preview, which the STEP renderer draws one\nscene-quality tier up (`kit/viewport/previewViewScene.js`) without changing the Display\nsetting: while Preview is open the loader samples Preview's camera and asks at its\ntier, both scenes reconcile the same published mesh, and only the editor's adopts it. The chrome does not report detail levels: the STEP renderer\nrecords first geometry and standard detail for benchmark harnesses only\n(`window.__cadViewerQuality`, `useViewportQualityStatus`), and background file\nwriting stays quiet. That record lives in the STEP surface, so it takes a scheduler\nsnapshot only when the status it derives would change (`lodSnapshotStatusKey`): a\npreview orbit resamples the camera on every frame, and a snapshot per frame would\nre-render the whole surface per frame — while a model streams in, redoing the\npartial model's derived state each time.\n\n## 2. What refinement samples\n\nRefinement uses the camera and the disposable memory budget.\n\nStatic assemblies sample full transformed occurrence bounds against the camera\nfrustum, refining a component when at least one occurrence is on screen.\nOffscreen components stay displayed: ordinary camera sampling retains their\nexisting detail, and memory pressure can coarsen them before visible\ncomponents. Unknown or not-yet-adopted bounds remain eligible.\n\nA stationary camera requests the final level implied by the existing hysteresis\nthresholds directly. If admission refuses that level, strictly intermediate\nlevels can supply measured replacement sizes for another try.\n\n**Resampling a motionless viewport is not work.** A sample carrying the same\ncamera, viewport and per-component distances, visibility and selection neither\nrestarts the settle debounce nor republishes status, so a viewport nobody is\ntouching reaches settled quality and stays there. An idle scheduler reports\nmemory-limited targets separately from settled quality. Camera and distance\ncomparisons ignore floating-point noise at a 1e-10 relative tolerance, measured\nagainst the last meaningful sample so accumulated movement still triggers work.\nVisibility, selection, quality changes and explicit retries remain meaningful.\n\nScenes with joints, embedded animation, drawing poses or an active/collapsing\nexploded view keep conservative eligibility, including paused or disabled pose\ncapabilities. Authored visibility and material flags are not LOD filters.\n\n## 3. Admission and memory pressure\n\nFailed loads stay parked; denied admission retries only after the displayed\nlevel or camera intent changes. Pressure-driven coarsening caps subsequent\nrefinement until the camera or viewport changes, preventing upgrade/downgrade\nloops. Mesh-bound and clip-plane updates do not reset that cap. Pressure\ncoarsening remains singleton.\n\nAdmission can reclaim idle tessellation workers and retry while preserving\nactive consumers. Its ledger samples each live worker's own retained estimate\nbefore admission, so a large component does not inflate every worker's charge.\nRefinement reserves both replacement arrays and worker scratch space, and\nincludes the coarse tier's relaxed angular tolerance in its estimate.\n\nDisplay arrays shared with asset caches have one CPU charge for the entire\nbacking allocation, including unused sections of packed buffers. GPU charges\nuse uploaded view sizes; CPU-only edge inputs and picking allocations are\naccounted for separately.\n\nA component that cannot fit even at the coarse level reports a limitation and\npreserves the current view. **Estimates and sampled resource totals are a soft\nbudget, not a hard browser RSS limit.**\n\n## 4. The replacement batch\n\nThe scheduler holds at most four distinct replacement CIDs across loading,\nready payloads and actual scene adoption. Its render and late-selector\npreparation share one loader lane, and only one atomic mesh/reference\npublication awaits adoption.\n\nThe Viewer uses a 128 ms first-ready collection deadline so serialized cached\nreads can fill the four-component batch. It may publish a ready subset beside\none unfinished carryover; it does not guarantee selector, worker or scene\nreadiness. No fifth replacement starts. Admitted refinements keep filling the\nbatch while exact sibling reservations allow it, even after the coarse pressure\nthreshold is crossed; a denied reservation flushes the ready subset.\n\nSeparate user-driven topology requests keep their existing worker admission and\ncache/picking accounting; they are not included in the scheduler's occupied-CID\ncount. Topology-only interactions also release idle workers after their sibling\nrequests drain.\n\nActual payload backing allocations are reconciled before another sibling is\nadmitted. Temporary sibling-capacity denials flush and retry after ownership\nchanges; they do not permanently park a target. Displayed levels and measured\ncurrent sizes remain unchanged until the complete exact batch adopts.\n\n**Adoption receipts.** Replacement admission stays held until the viewer adopts\neach current component payload at every occurrence and accounts for its scene\nownership. This acknowledgment schedules rendering; it is not a GPU\nupload-completion fence, and modeled upload ownership remains separate. A\nsuperseding progressive publication can satisfy it only with the same context,\nrevision, occurrence set and exact payload. Display geometry and demanded\nselectors publish as one matching state pair, with commit receipts fencing\nabandoned or replayed React updates.\n\nEvery publication lands in the ONE STEP scene the viewport adopted\n(`renderers/step/scene/stepScene.js`): while the structural build key holds for\nthe same model, the live build reconciles its records in place, and the viewport\nis told the scene changed (`viewport.commitScene()`) rather than handed a new\nscene — so a progressive open or a detail swap never re-dresses every material or\nre-adopts anything. The camera frames the model once, on its first publish, on\nthe box `assembly.json` declares for the whole of it (`bbox`, carried as the\ncomposition's `declaredBounds`): the scene's `restBounds` is that box from the\nstart, so the framing, the orbit pivot, the zoom ruler, Zoom to fit, preview's\nturntable and the ground's size are final before most components have arrived,\nand later publishes move none of them. A descriptor without a box leaves the\nscene `complete: false` until the last component is in: it is framed on what\narrived first and once more when it is whole, unless the person has taken the\ncamera by then. The camera sample that drives all of this is taken when the\nviewport says the camera settled (`onCameraSettled`: a move, a preview orbit, or\na resize, which can expose a part without moving the camera) and when the\nselection changes.\n\nA static component publication can reuse the main adoption's completed reset\nonly in that same React render. Later visual or clipping changes still run\nnormally, as do transitions out of modules, animation, drawings or poses.\n\nProgressive display and later detail swaps share unchanged occurrence rows and\ntree metadata; changing tessellation alone does not rebuild every tree leaf.\nPlacement, appearance and changed bounds still update their records. Selection\npruning preserves unchanged selected, referenced and hidden ID arrays,\npreventing detail publications from retaining historical workspace render\ncontexts through unnecessary selection updates.\n\nDisplay raycast accelerators are requested only when a picking ray reaches\ncomponent bounds, then queued during idle time for one worker at a time.\nAdmission covers private input copies, worker scratch and the returned tree;\ndisplayed arrays stay attached and unchanged. Releasing the last geometry owner\ncancels its build, and stale results cannot attach to replacement geometry. The\nfirst pick remains exact and may cost more on a dense component; merely loading\nor refining an assembly does not build an accelerator for every component.\nInputs with a separate merged face-selection proxy still build that proxy's\naccelerator on the main thread during idle time. Canonical STEP selectors use\nthe display meshes and do not enter that separate path.\n\nDiagnostic snapshots identify scheduler-only ownership, batch sizes and seal\nreasons. The internal size-one control uses the same admission/publication path\nas groups of four.\n\n## 5. Cancellation, failure and cleanup\n\nCancellation requests cleanup: switch, abort or unmount retains an outstanding\nreservation until actual replacement, restoration or complete disposal proves\nthat the renderer has released its previous owner. Pending component maps\nremain separate from adopted maps.\n\nA failed scene update clears its partial records before rebuilding the last\nadopted mesh/selector pair; it never reconciles against already-disposed\nrecords. Restoration preserves unrelated progressive components and completed\nselector loads. A second construction failure stops detail work and reports an\nerror. Cleanup failure keeps ownership charged until a real cleanup retry\nsucceeds.\n\nA cancelled batch that actually adopted remains a displayed payload owner even\nthough its scheduler levels are not promoted, so cancellation does not evict\nits exact cache entries.\n\n## 6. Following an active build\n\nSTEP entries always follow active edits, showing the root preview before its\nSTEP save. Run the model normally; existing decorators need no new imports, and\nthe daemon must be running for live updates.\n\n**The feed.** Updates arrive through a held request that wakes when this\noutput's build ledger changes. Unrelated jobs do not wake the tab. The server\nadmits 32 waiters independently of kernel workers; excess tabs retry every\n500 ms. A server that answers at once instead (a host relaying requests through a\nfew slots all its views share, where a held request would take one) is paced by the feed: an answer\nwith nothing new waits out the rest of a second, and news is asked after again\nwithin a tenth of one. An idle heartbeat revalidates saved bytes and missing geometry; closing\nor switching the tab cancels the request. Older status responses cannot\noverwrite newer cached progress, and saved revisions are verified from one\ncoherent file snapshot. Restarting the daemon expires the ephemeral session,\nand rerunning the model reconnects it.\n\n**What stays on screen.** The prior model stays visible while the next request\nbuilds; failed updates remain visible while an idle disconnected feed retries\nquietly. A complete model of the same file — a part's or an assembly's — also\nremains visible while its rewritten file is rebuilt and while the replacement\nmeshes load (`replacingSameFileMesh`, `awaitingSameFileRevision`), reported as an\nupdate (\"Updating model…\"), never as the loading screen — also when edits come\nfaster than revisions load, and one revision's load is cancelled for the next\n(`meshStateAfterCancelledLoad`). Complete displayed component arrays remain available while a\nreplacement stages or fails. Reuse requires the same runtime surface input,\nconcrete surface object and tessellation; placements and appearance come from\nthe new tree. Selection, measurements and reference copying wait for matching\nnew geometry. A failed replacement preserves the view and reports its error;\nonly that file/hash stops retrying automatically. A first load that fails part\nway keeps the parts it drew under its error, and refinement goes on over them: a\ndetail swap changes geometry only, never whether the load finished or failed\n(`detailSwapMeshState`). STEP pose and animation\nmetadata use their normal loading path, without a promise to retain the\nprevious pose. Snapshot source isolation is unchanged.\n\n**Overlapping loads.** Geometry and reference loads own their cancellation\nindependently; a superseded request cannot cancel its replacement. Topology\nrequests for one file revision union rather than supersede: a batch in flight\nfinishes, and one no longer wanted is simply not published.\n\n**After a save.** The view shows the saved file. When a build writes it, the new\nrevision replaces the one on screen in place, keeping every component whose identity\nit shares; while the build runs the model on screen says it is updating, and a failed\nbuild leaves it on screen under its alert.\n\n**What the viewport shows.** The navbar carries no status. Opening and\nupdating are the viewport's loading overlay (`ViewerLoadingOverlay`). A failure is\na card over the viewport (`kit/status/ViewerAlertCard.jsx`) with its explanation,\nits next step and the full diagnostic under Details: an error always shows, and a\nfailed update the model survives (`blocking: false`, the previous version still on\nscreen) has a Dismiss button and stays dismissed until the alert changes, or\nclears and is raised again. A warning is a card too, one that can be dismissed\nwhile the model is on screen: the card is the one place a problem is said. Once a\nusable current view is displayed, saving, successful completion, idle edit-feed\nstate and routine refinement stay quiet. A sidecar this build cannot read raises\nno alert: the model renders with no kinematics, no materials and no routine, and\nthe migration is announced where it can be acted on — the build and the cad\nskill. Existing usable views remain visible during updates and failures.\n\n**Opening.** Opening shows one step line — **Finding file**, **Reading\nmodel**, **Loading geometry**, or **Preparing view** — with no headline above\nit; the loading mark itself says a model is opening. Counts measure\ncompleted geometry items in the current stage, not assembly occurrences or an\noverall ETA; uncounted stages are indeterminate. Render initialization uses the\nsame indicator against the destination backdrop until its first usable frame.\nLong waits show elapsed time; interrupted progress explains that the viewer is\nwaiting for a response before offering recovery. Selection and edge preparation\nreport beside their controls, not as whole-model loading.\n","packages/ui/docs/render-mode.md":"# Display modes\n\nThe Viewer and snapshots share one strict sparse contract. Core's `common/viewSettings.js`\nvalidates it and resolves the mode, or Preview's Render, into renderer groups.\n`common/sceneSettings.js` translates those groups into the scene recipe. The binding\ncontrols are in [settings-ui.md](settings-ui.md#display-settings-and-section-primitives).\n\nA file's display is a mode and the Grid switch. A mode is a look at its own defaults, with\nnothing to set. Render is no display mode: it is the photographic look Preview and a\nsnapshot's `--preview` ask for on their own, over the same display, with Preview's own\nsettings (the viewer's alone, kept beside the display).\n\n| Mode | Surface / edge recipe |\n| --- | --- |\n| Solid (default) | Shaded, the model's visible CAD edges |\n| Shaded | Shaded, no edges |\n| Parts | Solid, each part its own palette colour |\n| X-ray | 22% opacity, all edges |\n| Hidden line | Depth-only surfaces, visible edges (light on a dark backdrop) |\n| Wireframe | No fill, all edges (light on a dark backdrop) |\n| Render (Preview's, asked for on its own) | Shaded, no edges, no grid, photographic studio |\n\nA file without CAD edges (STL, 3MF, GLB, a robot) offers Solid, drawn without edges, and\nParts where it has several parts, objects or links; Shaded would be Solid again there. The\ngrid is off by default and the same measuring grid in every mode. Appearance, camera, Clip\nand Explode are shared. Camera defaults to orthographic with a 50mm lens; Render resolves to\nperspective without overwriting that shared camera choice.\n\n## Sparse public contract\n\n```json\n{ \"mode\": \"parts\", \"grid\": true, \"appearance\": \"light\", \"camera\": { \"projection\": \"orthographic\" } }\n```\n\nOmitted mode is Solid, and `mode` is one of the six; `grid` is a boolean, off unless said.\nThe display also takes `clip` and `exploded`. Anything else is refused: there are no\nper-mode settings, style groups or axes. Preview's own settings (`parts`, `lighting`,\n`background`, `floor`) are a separate sparse object (`normalizePreviewSettings`): `parts`\ncontains `colorMode` (`original`, `single`, `by-part`), `color` and `colors`; `lighting`\ncontains `quality`, `exposure`, `rotation`, `size`, `fill`; `background` contains `color`,\n`opacity`; `floor` contains `enabled`, `placement`, `color`, `opacity`, `finish`.\n\nViewer appearance inherits the host; snapshots default to light. Lighting quality inherits\nPreview in a viewer and Final in a snapshot. There is no Custom state, legacy alias or\nmigration. A stored display or Preview settings record is read setting by setting at the\nfile-session read boundary (`readViewSettings`, `readPreviewSettings`): what this build\ncannot read (an older per-mode tree, a mode it no longer has) is left out, the rest kept.\nThe same display shape powers live `setDisplaySettings` and snapshot `--display`; either\nrefuses `mode: \"render\"` as it refuses any mode it does not know. A snapshot turns the grid\non with `--grid` (`display.grid`) and asks for Preview's look with `--preview` (a job's\n`\"preview\": true`, the page's own flag), at Preview's defaults. `--camera` holds\npose/framing only. A Quick Edit's views carry the mode and the grid (`display.mode`,\n`display.grid`) with Clip and Explode, so a snapshot of them replays the view.\n\n## State and updates\n\n`kit/view-settings/viewSettingsStore.js` owns one sparse display, and Preview's settings,\nper mounted file. `selectMode(mode)` selects a mode; `setGrid(on)` switches the grid (off\nis left unsaid); `patch(patch)` merges the display's shared fields. `patchPreview(patch)`\nmerges Preview's settings and `resetPreview()` removes them. `restore(display, preview)`\nreplaces state once on mount; persistence reads the latest `getSnapshot().display` and\n`.preview`. `renderScene()` resolves Preview's scene without committing anything.\n\nNo action reframes the camera, changes model pose, selection or visibility. The store\npreserves equal JSON branches; a Preview edit leaves the display's scene identity unchanged.\nClip edits do not invalidate materials, lighting, geometry or camera. No-op actions publish\nnothing. Do not merge captured props, persist resolved defaults or keep a second display\nstate.\n\nThe view injects its offered `modes` and `settings` (`edges`, `clip`, `exploded`). Unsupported\nsaved modes resolve to Solid without rewriting storage; unoffered settings do not affect the\nscene. The shared UI discovers no host APIs.\n\n## Rendering costs and camera behavior\n\nThe photographic rig stays lazy. Preview's Render creates its photographic\nsoftbox environment; the other modes use neutral CAD lighting. Authored materials are read-only. Color\noverrides are presentation settings, not edits to the model or its material\nassignments.\n\n| Lighting quality | Screen error | Shadow map | Environment | Capture scale | Samples at rest |\n| --- | --- | --- | --- | --- | --- |\n| Preview | 1 px | 2048 px | 256 px | 1× | 32 |\n| Final | 0.25 px | 4096 px | 512 px | 2× | 64 |\n\nRender is drawn through the studio's frame (core's `studioFrame.js`, see\n[the render pipeline](../../core/docs/render-pipeline.md#the-studios-frame)): ambient\nocclusion from the frame's depth, and, while the view rests, a picture that converges over\nits samples: anti-aliased, its occlusion smooth, the key's shadows a softbox's (Softbox size\nsets how soft). The viewport's loop (`useViewerRuntime`) draws each frame through it and says\nwhether anything but the camera changed (`runtime.requestRender`/`requestFrame` say so); while\nsamples are owed it asks for the next frame itself, and once the picture has converged it\ndraws nothing more. A drag, a routine's frame or a setting starts the picture again; the\ndamped tail of an orbit, moving the model less than a quarter of a pixel, does not. So\norbiting pays one draw and a few small passes a frame (about 1.5 ms more at 3024 × 1964 on\nan M1 Max) and a still view converges in about half a second. A snapshot draws its still's\n32 samples at once (video frames one). Software WebGL has no frame: one draw, as before.\n\nEach interactive viewport keeps its renderer (a view of the page's one GPU device:\n[the GPU device](cad-renderer.md#the-gpu-device)), canvas and camera controls\nacross every mode and settings edit. It uses shadow-compatible conventional\ndepth throughout, fitting near/far planes to the current model, closeup records\nand floor/grid planes on every frame; a perspective near plane stays at or beyond\n1/256 of the orbit pivot's depth, so a closeup never trades depth resolution for\na near plane at the eye. The grid's bounds also fit the far plane;\nit is independent of the studio floor used by Render. The grid\n(`lib/viewer/stageGrid.js`, one shader quad the viewer and the snapshot both draw) is graph\npaper on the floor, after Tinkercad's workplane: thin minor lines at a round step (1, 2 or\n5 x 10^n) near twenty across the default framing at density 1, a heavier major line every 5 or\n10 of them, in one cool cyan-blue ink (`GRID_LINE_DEFAULTS`: `#4ea8cc` at 0.45 on a light\nbackdrop, `#5d9ac0` at 0.35 on a dark one; minors are the same ink at 0.6 of its strength), with\na red X and a green Y axis through the origin, anti-aliased in the fragment shader. Each family\nof lines fades only once its cells crowd under about 3.5 pixels, so there is no moire zoomed\nout or at a grazing angle; the sheet is at full strength to 1.5 framings from the origin, past\nthe default view's corners, and dissolves gently by 6, and fades a little as the plane turns\nedge-on, so it reaches the edges of the view yet has no edge of its own. Display has no separate Axes setting. The grid, the stage and the Render studio's floor are\nSIZED from the model's rest placement for every renderer, STEP included: a pose, a\nplaying routine, or a pose set in Preview never rescales or\nslides the ground; its height, the lighting and the shadow reach follow the model. Enabling photographic effects updates that live scene;\nit never clears or covers the canvas with a loading screen. Only actual context\nrecovery replaces the renderer. Quality never changes a mesh: every mode draws\ncadgen's one display mesh. Picking/topology stays demand-driven in every mode.\n\nEnabling Clip prepares its shader/cap variants before presentation. Moving an\nenabled cut stays live. Explicit neutral boundaries\nstill avoid unnecessary caps. See [responsive View updates](view-updates.md)\nfor scheduling, resource caching, loading presentation and capture readiness.\n\nRender's floor stands at the document's Z=0 plane (Model origin), at 60% opacity, its opacity\nletting geometry below that plane show; Lowest point is a choice away and is remembered in Render, as Glossy is. A snapshot's Render floor stands\nwhere the viewer's does: both resolve it from the same mode default (`RENDER_FLOOR_PLACEMENT`).\nRender's floor carries a studio's soft grounding shadow, as deep\nas the floor is opaque: darkest where the model touches it, the key light's cast\nshadow softening and fading away from the model. The viewer re-renders its\nshadows, and re-bakes this one, only for a change that can alter them: never\nwhile the camera alone moves, and never for a hover, a selection or any other\nhighlight, which leave every part casting as it did (a highlighted part keeps\nits shadow). While a routine plays or a pose is dragged, the key's cast shadow on the\nfloor follows every frame and the contact darkening under the model at most every\n100 ms; once the model stops, both are baked for its final pose. A floor at zero\nopacity bakes nothing, and a viewer that renders in software, whose key casts no\nshadow, draws no floor shadow.\nIts finish is Matte, or Glossy: a glossier floor that also reflects the model, crisp\nwhere the model meets it and softening and fading over the model's height, as a\npolished studio floor shows a product, in either appearance. The reflection is a\nsecond draw of the scene at half the canvas's resolution, before each frame the camera\nor the model moved in (about 4 ms of render CPU a frame on a large assembly); a hover\nor selection keeps the last one and catches up within 400 ms. A Matte floor allocates\nnone of it and draws nothing extra, and switching back to Matte releases it. In\nsoftware, a glossy floor reflects nothing.\nIts double-sided surface uses its actual elevation during\ncamera depth fitting, avoiding the origin-placement near-plane gap. Its color\nand opacity are independent of Background.\nThe floor has no edge, as a product shot's seamless sweep has none: it is at its\nopacity around the model and fades to nothing a few model radii out, its shadow and\nreflection with it, so a low camera or a wide view shows no horizon line or corner.\nBehind it, Background is a studio backdrop rather than a flat fill: its color lifted\ntoward white behind the middle of the frame, shaded toward the corners (more for a dark\ncolor) and above the horizon. Its opacity carries the gradient; at zero it draws nothing.\nLight Render defaults to a pure white background and retains the slightly gray\nfloor (`#e7e7e5`); dark Render's background and floor are one charcoal (`#202226`), never\nblack. Explicit background colors still override the mode default.\nExternal STEP pose and animation passes publish current placed bounds before\ndrawing, and so does the exploded view wherever it comes to rest (a slider move,\nthe end of its ease) and at most every 100 ms while it eases in or out. Depth and\nlighting follow moving parts without changing camera framing or the floor's\nfootprint.\n\nAll modes share surface-under-cursor zoom, zero-pose framing and the fit\npadding: 1.1 across, and 1.1 down on a square or narrower viewport, easing to\n1.25 at 16:9 and wider (`interactiveFitPadding`), so a wide view leaves room\nabove and below the model. Mode changes do not refit, and neither does a\nrebuild of the open file. Motion changes geometry, not what 100% means. A\nchanged camera clears current screen-space drawings; camera state republished\nunchanged after a runtime replacement does not.\n\n## Where the controls live\n\nDisplay is a dropdown from the Display tile at the strip's end (the current mode's sphere,\nbetween Projection and Preview: `kit/shell/DisplayPopover.jsx`), present for every 3D file:\nthe offered modes (`DisplayModeOptions.js`), then the Grid switch. On a phone it is the\nMore menu's Display submenu, the same list. Preview, a view of its own over the editor's,\ndraws the model in Render whatever the file's mode (the store's `renderScene()`, as\n`kit/viewport/previewViewScene.js` draws it), and the tools view never does. Preview's own\nsettings are its settings panel (`kit/shell/PreviewSettings.jsx`: Orbit, then\n`PreviewRenderSettings.jsx`), its edits the file's Preview settings. A file's model tree and Position are\nsections of the file sidebar (see\n[settings-ui.md](./settings-ui.md#the-file-sidebar-and-the-tool-stack)); nothing in them is\na display setting.\n\nDisplay and Preview, the view's controls at the strip's end, are offered by a 3D view\nalone, as its renderer declares to the shell (`useRendererShell`'s `previewable`):\n\n| Renderer | Files | Display and Preview |\n| --- | --- | --- |\n| `step` | STEP, STP | Yes |\n| `glb` | GLB | Yes |\n| `mesh` | STL, 3MF | Yes |\n| `robot` | URDF, SRDF, SDF | Yes |\n| `dxf` | DXF | No: a 2D drawing, on a surface of its own, without the shell |\n\nA view without them has no Display or Preview control, not a disabled one, and a\nrequest for Preview leaves its normal view as it is.\n","packages/ui/docs/render-types.md":"# Render types: capabilities and the backend contract\n\nBinding for viewer work that touches more than one file format. The rule this document\nexists to enforce:\n\n> Viewer code asks what a format **can do**, never what it **is**.\n\nEvery `renderFormat === RENDER_FORMAT.X` check is a place a new format must be\nhand-added, and a place an improvement to one format fails to reach the others. That is\nnot theoretical. The Orbit button was gated off per format independently and had to be\nfixed twice; when it was finally enabled for DXF, the button still did nothing because\n**four** separate format checks stood between it and preview mode (the toolbar gate, the\nworkspace handler bail, the pane's `previewMode={dxfMode ? false : ...}`, and an effect\nthat force-exited DXF from preview). Another format grew an entire parallel export path to\nan endpoint the server does not implement.\n\n## Scope\n\nThis is the rule inside the STEP renderer (`src/renderers/step`). A DXF,\na GLB, a triangle mesh (STL, 3MF) and a robot description (URDF, SRDF, SDF) have renderers\nof their own (`src/renderers/dxf`, `src/renderers/glb`, `src/renderers/mesh`,\n`src/renderers/robot`; see [CAD renderer](cad-renderer.md#kit)): a vertical slice owns its\ntools, tabs and scene outright and consults no capability table. The `dxf`, `glb`, `stl`,\n`3mf`, `urdf`, `srdf` and `sdf` rows below remain for what is not a renderer: the file\nlist's icon and label. The headless snapshot renderer consults no table either: it draws\neach of those families with the scene builder its renderer uses\n([one scene builder per family](cad-renderer.md#one-scene-builder-per-family-the-viewer-and-the-snapshot-cli)).\n\nThe DXF slice does not even have a scene: it is a canvas painted from\n`GET /__cad/drawing`, so none of the viewport capabilities below describes it — and its\nrow says so, with `assetKind: drawing` and no viewport tools (its own are its renderer's). `cadgen dxf snapshot` paints the same\npayload with the same code (`@text-to-cad/core/lib/drawing2d`), so the CLI cannot produce a\npicture the pane could not.\n\n## The capability registry\n\n`packages/core/src/lib/renderCapabilities.js` — one frozen table, keyed by render\nformat. Pure data: no behaviour, no imports beyond the format enum.\n\n| Capability | Meaning |\n|---|---|\n| `assetKind` | Which asset a format LOADS: `mesh`, `drawing`, `robot`. |\n| `iconKind` | The file-list glyph. |\n| `tools` | `select`, `pan`, `draw`, `orbit`, `screenshot`. Orbit and screenshot are true for every format WITH a viewport — they act on the viewport, not the geometry. `dxf` claims none of them: it has no viewport, and its tools (Select, Annotate, Measure) are its renderer's own, over a 2D canvas. |\n| `parts` | Per-part selection, hiding, isolate, assembly tree. |\n| `topology` | Face/edge/vertex references. Implies `parts`. |\n| `params` | `sidecar` (the model's `@step(pose=...)` block), or `null`. |\n| `animations` | Has animation clips, so transport controls apply. |\n| `artifactManaged` | Builds a package before it can render. A format listed here that the backend cannot produce a package for blocks forever, so a format the viewer renders from its own file belongs out. |\n\nWhat a 3D file's tools and view offer — Measure, Clip, Explode, the display modes — is not\nin this table: each renderer offers its own tools ([CAD renderer](cad-renderer.md#tools-on-a-scene-of-triangles)),\nand `viewFeaturesForKind` (`@text-to-cad/core/common/viewSettings.js`) is the one rule of\nwhat a file's view offers, for the viewer and a snapshot alike.\n\n### Rules\n\n- Add a capability when the **second** format needs it, never speculatively.\n- An unknown format resolves to the conservative default row (everything optional off).\n  Deliberately *not* `normalizeRenderFormat`, which resolves unknowns to STEP and would\n  hand an unrecognised entry STEP's full capability set.\n- Capabilities decide **which** panels and tools mount. Format-specific *content* — STEP's\n  tree — stays format-specific.\n\n## The content signal\n\n`selectedViewportContent` in the STEP renderer is the single answer to \"is there anything on\nscreen?\", its loaded mesh data. Toolbar gates, the CTA, preview mode, the viewport\ncontext menu and alert blocking all read it, rather than each one re-deriving the answer\nper format.\n\n## The render-backend contract\n\nThe kit's viewport (`renderers/kit/shell/ShellViewport.jsx`) is the shell and owns the camera,\n`OrbitControls`, the scene stage, frame presentation, the Draw overlay, screenshots and the\nimperative viewer API. A renderer's **scene** (the contract `renderers/kit/scene.js` re-exports from core; STEP's is\n`renderers/step/scene/stepScene.js`) owns geometry only:\n\n1. **Consume content**: the document its renderer loaded (a STEP's mesh data, a GLB's native\n   scene, a mesh file's objects, a robot's parts). A GLB's, a mesh's and a robot's scene is\n   built in `@text-to-cad/core`, where the snapshot CLI builds the same one.\n2. **Publish bounds** so the shared fit and its zoom baseline work. The mesh\n   path does this via `applyRuntimeModelBounds` after composing; a backend with no mesh\n   calls back with its own bounds instead.\n3. **Optionally install loop-tuning hooks** on the runtime. All are inert unless set, so\n   the mesh path is unaffected:\n   - `renderOnDemandOnly` — do not hold the render loop open for a whole gesture.\n   - `idleQualityDelayMs` — raise the idle-restore delay.\n   - `onIdleQualityRestore` — restore quality before the pixel ratio, so the expensive\n     frame and the drawing-buffer reallocation do not land on the same vsync.\n   - `resolveExtraPixelRatioCap` — cap resolution below the shared caps.\n\nA backend never reaches into the camera, controls or stage. If it needs something from\nthem, that is a shell feature and belongs in the shell where every format gets it.\n\n### Adding a format\n\nDeclare a registry row, implement a backend, add a fixture to the sweep. Do not touch the\nshell. If you find yourself adding a format check to `FloatingToolBar` or to anything under\n`renderers/step/scene/`, the capability you need is missing from the table.\n\n## Enforcement\n\nA ratcheting policy test in the repo where this app is developed counts identity\nchecks in non-test client code: the number may only go down. It also asserts a\ngrowing set of files at **zero** — the toolbar, every module of the STEP scene\n(`renderers/step/scene/`), the alert\nbuilder, the file-list icon and status, and the home screen — since those are the surfaces\nevery format flows through. Lower the budgets in the same commit that removes checks.\n\nWhat is left is deliberate. `useCadAssets` is allowlisted: choosing and running a loader\nper format is its whole job, and the `assetKind` field names *which* loader without\npretending the implementations are the same. `stepArtifactStatus.js` keeps its checks\nbecause STEP package error codes, the `stale` flag and the renderable-GLB fallback are\nSTEP vocabulary. STEP is now the only artifact-managed format: a DXF is rendered from\nthe file that is on disk — the backend flattens those bytes to 2D primitives per request\nand never owns a package for one (`owns_dxf_path` always answers False).\n\n## Standing gate\n\nThe `web` CI job's `scripts/test/test-viewer-browser.sh` opens one fixture per load path\n(STEP, STL, DXF, URDF) through the real backend and the bundled viewer and asserts each\ndraws something with no page errors: a blank-but-error-free viewport is the signature\nfailure here (a shader that fails to compile, a gate that hides the geometry). It runs on\nevery change to shared viewer code; there is no separate manual sweep.\n\n## Known non-uniformities\n\nRecorded so they are not mistaken for bugs, and so the next person knows the cost:\n\n\n## Scene recipe conformance\n\nInspect and Render resolve through `@text-to-cad/core/common/sceneSettings.js`.\nInspect's fixed light/dark workbench basis follows app appearance; Render uses\nits independent studio configuration. Legacy CAD theme preferences are not an\ninput. The internal `themeSettings` recipe remains a renderer implementation\ndetail, normalized through the shared core schema so every backend receives the\nsame lighting, materials and stage settings.\n\nThe browser qualification in `scripts/test/test-viewer-browser.sh` opens every\nload path through the real backend and bundle and checks mode framing, that a saved\nrevision keeps the camera and that Zoom to fit frames the revision; picking and the\ndisplay presets are the `packages/ui` browser specs'. Both use\nisolated generated fixtures rather than a sweep of saved theme presets.\n","packages/ui/docs/renderers.md":"# File renderers\n\nThe viewer renderers, `cad` (STEP), `dxf`, `glb`, `mesh` (STL, 3MF) and `robot` (URDF, SRDF, SDF), and\nthe kit and shell they are built on are in [CAD renderer](cad-renderer.md). They are\nthis package's renderers because every host registers them.\n\nA renderer only one host registers belongs to that host, with its own\ndependencies and tests. It uses only this package's public exports, and they are\nwhat a host renderer may rely on:\n\n- `defineFileRenderer` and the registration contract from `@text-to-cad/ui/file-viewer`\n  ([FileViewer](file-viewer.md)), including `fallback: true` for the one\n  renderer that takes files nothing else matches.\n- The file it is handed (`FileMetadata`) and its `FileSource`, which names and\n  describes files and reads nothing else: a renderer reads a file's content through\n  what its host injects into it (the CAD renderers take a `CadWorkspaceService`).\n  Every resource a renderer acquires carries a release; it returns that as the\n  prepared document's `dispose`, and FileViewer releases it when the request is\n  cancelled, the file changes, or the view unmounts.\n- The host ports in `@text-to-cad/ui/host`: `useViewerHost`, and `PromptContextAction`\n  for prompt delivery.\n- Shared chrome from `@text-to-cad/ui/navigation` (`EmptyState`),\n  `@text-to-cad/ui/primitives/*` and `@text-to-cad/ui/utils`, styled by this\n  package's `styles.css` and the host's own Tailwind build.\n","packages/ui/docs/settings-ui.md":"# Viewer interaction and settings design system\n\nThis is the binding contract for the viewer's tools, its file sidebar and its\nsettings, in both apps. Build from the shared pieces — the\n[toolbar button](../src/primitives/toolbar-button.jsx),\n[FloatingToolBar](../src/renderers/kit/tools/FloatingToolBar.js),\n[SubToolbar](../src/renderers/kit/tools/SubToolbar.jsx),\n[ToolPopover](../src/renderers/kit/tools/ToolPopover.jsx),\n[FileSidebar](../src/renderers/kit/tools/FileSidebar.jsx),\n[ToolStack](../src/renderers/kit/tools/ToolStack.jsx),\n[ToolPanel](../src/renderers/kit/tools/ToolPanel.jsx),\n[the floating surface](../src/lib/floatingSurface.js) and the\n[FileSheet primitives](../src/renderers/kit/inspector/FileSheet.js) — and extend\nthem rather than recreating their layout in a renderer. Environmental effects\nbelong to apps through the [host contract](viewer-host.md).\n\n## Ownership and layout\n\nFileViewer owns the navbar — the logo's app menu ([Settings](#settings)) included, built from\nthe host's `links` and the `appSettings` `CadViewer` hands it — and the file explorer (a\npopover under the file's name). RendererShell owns the scene's chrome: the tool strip and\nthe view's controls at its end (Display, Preview), the sub-toolbar of the tool in hand under\nit, the file sidebar, Quick Edit, the view cube and Preview. Renderers supply\ntheir tools, their tools' rows, their sidebar sections and their document state; the shell never\ninspects a format's parts, joints or topology. No pick or tool opens or closes the explorer.\n\n| The host (web, desktop) supplies | The shared UI decides |\n| --- | --- |\n| Where the tab record lives (`TabRecordStorage`: the web's sessionStorage, the CAD app's memory) | The record: its settings (the file sidebar's and tool stack's layout, the appearance, the home's layout) and the view of the file on screen (its camera, Display settings, its playback settings, and renderer slices), what is written when, and what is never stored (`@text-to-cad/ui/tab-store`, `kit/shell/fileView.js`) |\n| `navigation.home`, `links` (its version, GitHub, Discord, where a new issue opens, how a link opens), `appSettings` (the person's own settings), `colorScheme` (the person's theme and how it changes) | The navbar's order and look, over a file and over the home, the app menu (one menu in the viewer and on the home) and what a new issue says |\n| `host.files`, `fileActions`, `navigation`, `clipboard`, `promptContext`, `promptFiles` | When a copy, capture or open happens and what it carries |\n| `host.environment`: color scheme, keyboard `platform`, `reducedMotion` | How the chrome honours them |\n| `onError`, for errors the viewer hands up | Preview, tooltips, keyboard scope, the tool strip and sidebar, the camera |\n\nHosts pass no Preview, chrome-visibility or notification props; there are\nnone.\n\n- **Navbar.** One row, the same in every app, over every file and over the home. Left: the text-to-cad \"C\"\n  logo, named Menu, the size of the row's icon buttons, with no chevron, which opens the\n  **app menu** (see [Settings](#settings)) — a host with no home has it too — then, where the\n  host keeps the view's own history (`history`: the CAD app's views, which no browser holds),\n  **Back** and **Forward**, two arrows walking it as a browser's do, from file to file and to\n  and from the home (a step like a file), each there always and disabled where it has\n  nowhere to go; then the open file's name and its ⋯. Each of these that\n  is a control (`NAV_ITEM_CLASS`) is transparent at rest, takes the same accent background\n  while the pointer is on it and while its menu is open, has a pointer cursor and no tooltip:\n  it says what it is. The name\n  is that control where it opens the explorer (below), where the host's files can be\n  browsed; where they cannot it is plain text. The ⋯\n  is the file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host can do it, and no ⋯ where it can do none. The name has\n  no right-click menu and there are no crumbs. The left's controls sit edge to edge, spaced\n  by their own padding alone: the logo's 24px button, Back, Forward and the ⋯ in 20px ones,\n  and the name 4px in from its edges, so about 7px separate one glyph from the next. Right, only what a host or a file adds: first, the\n  update — a blue download button, there only when the host\n  found a newer release, whose menu says the step to it, how this host updates and what\n  is new. Then, while a person has put an alert card away: the card's own icon (a circle\n  with an exclamation mark, red for an error and amber for a warning, named and hinted by\n  the alert's title), which brings the card back and goes with it — the renderer's one\n  navbar action. Then, where the host shows the view small (inline in a conversation,\n  where the view is the whole viewer), its **Full size**, corner brackets. A CAD file publishes no navbar action but that\n  icon. The person's own settings, the theme among them, are never here: they are the app\n  menu's.\n\n  *Design note: the strip is for tools; the view's controls are the navbar's.* What a host or\n  the file adds (the update, an alert's icon, Full size) is the navbar's right end, and last\n  after them the view's own controls, **Display** and **Preview** (`navbarSlot`): they act on\n  the whole view, whatever tool is in hand, so they are no tools. In Preview the same place\n  holds its own controls, **Preview settings** and **Exit preview**. A host with no navbar has\n  them over the view instead: Display and Preview ending the strip after a thin rule, the\n  Preview's pair in the view's top-right corner. A new per-file button that is no tool goes\n  with them.\n\n  A host's home has the same navbar, with only the logo and its app menu (without Back to\n  files) and, where the host keeps the view's history, **Back** and **Forward** at the left\n  and, at the right end, the host's update, when there is one, and **Full size**, where the\n  host shows the view small — the same buttons, sizes and places as over a file. Under it,\n  the page: the TEXTTOCAD wordmark and byline, then GitHub (its mark, a link to the project:\n  it is open source), Discord and X, as icon links (GitHub and Discord are the app menu's\n  too, X its footer's \"Made by\"; no light/dark button: Theme is the menu's), then Recent Files with its search, grid/list switch\n  and Open where the host has a file chooser: the models opened before, pinned first, as cards\n  or rows.\n  The update shows only for a release\n  later than the version the page names, whatever the host says. A file that will not open\n  shows a page under the navbar: \"File does not exist\" and the file's path, or \"Could not\n  open that file\" and the reason, each with **Go home** where the host has a home. A view\n  drawn as a picture (`compact`, the home's thumbnails) has no navbar either, and draws the\n  model alone: no tools, view actions, cube or Quick Edit.\n- **File explorer** is a popover under the file's name (`FolderExplorer.jsx`), aligned to\n  the name's left edge, 18rem wide and never resized, as tall as its rows up to 28rem or\n  the room below it: past that its list scrolls. It floats over the view and the file\n  sidebar, so opening it resizes and moves nothing. It starts in the open file's folder and\n  lists one folder at a time — its subfolders, then its CAD files — in compact rows: one 24px\n  line of 12px text each, its kind a small light glyph in one column 5px in from the row's\n  left end and 4px before the name (a folder its disclosure chevron, with no folder icon; a\n  file its type's icon), as the file sidebar's trees draw theirs. A press on a subfolder opens it\n  inline, under itself, its rows 10px further in and hung from a faint line under its\n  chevron, read once when it first opens; another press closes it, and Right and Left do the\n  same from the keyboard. A double-click opens it in the explorer's place, as a crumb does —\n  the breadcrumb then names it. The breadcrumb is\n  as many of the last three folders as fit whole: every crumb is its folder's whole name, the\n  farthest giving way first to the \"…\" (an ellipsis icon), which lists every folder above the crumbs to jump to,\n  and only the folder it is in, left alone beside the \"…\", is cut (its name a hint while it\n  is). The folder it is in reads in the foreground color, the others muted; there is no up\n  arrow, and the open file is highlighted, the list opening scrolled to centre its row, once (a\n  long folder's too). Opened, its filter has the keyboard, so typing\n  finds a file at once; on a phone the explorer itself holds focus, keeping the keyboard\n  down. \"Filter files...\" under the breadcrumb searches\n  every file nested under the folder (the server's bounded search, debounced 150ms, each\n  keystroke cancelling the search before it); a search that stopped early says \"Stopped\n  early. Keep typing to narrow the search.\" Up and Down move between the filter and the\n  rows on screen, the rows of a folder open inline among them. A pick opens the file and\n  closes it, as do Escape and a press anywhere outside it.\n- **Tool strip** at the top centre, 8px down, never wider than the view less 8px at each\n  side (it wraps before it would be): the tools, left to right, in **two sections** parted by one\n  thin rule (a tool's `group`: the tools that pick, Select, Pencil, Shapes and Measure; then the\n  model's effects, Explode and Clip), and, where there is no navbar, the view's controls,\n  **Display** and **Preview**, directly after the last tool, in the same second section with no rule\n  between (a file with only tools that pick has its view's controls after a rule, as their own second\n  section). Where a navbar holds the view's controls the strip is the tools alone: below. 24px tiles with 14px icons,\n  an even 4px apart, a tool's chevron touching its button, in a 34px row (`FloatingToolBar`,\n  `data-cad-toolbar`). A file with no tools has no strip. On a phone\n  ([mobile](#the-file-sidebar-and-the-tool-stack)) the tiles are 36px with 18px icons, a chevron\n  20px wide, in one row that never wraps. Centre it on the viewport whenever it fits;\n  clamp its position to keep 8px from the edges and from the file sidebar's tile at its left.\n  Its available width still uses all the room after that tile. What does not fit goes, from the\n  strip's end (the view's controls where the strip has them, then the tools from the last), into **More tools** (an ellipsis, last), a tool a row ticked while it is\n  in hand and Display opening its popover from More (`touchStripFit`).\n- **Sub-toolbar** under the strip, 8px below it, centred: the tool in hand's one row of\n  controls (`SubToolbar`, `data-cad-sub-toolbar`), there for as long as the tool is up (see\n  [Tools and lifecycle](#tools-and-lifecycle)). Select and Measure have none. On a phone its\n  buttons are the strip's 36px tiles and its sliders as tall, the slider rows 20rem wide.\n- **Tool stack** under the sub-toolbar, in the same centred column: a renderer's own\n  `toolPanels`, where it hands any (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)). The column\n  stops 8px above the cube's line (`VIEWPORT_STACK_BOTTOM`), so it never runs past the\n  view.\n- **File sidebar** at the top-left, 8px in: a toolbar of tiles, one per section of what there\n  is to read of the file — a STEP assembly's Model tree, a robot's Links, Position, an SDF's\n  metadata, the routines' Animation — whatever tool is in hand; a tile opens its section in a\n  panel under the tiles, one at a time, down to 8px off the view's bottom at most. On a phone it\n  is one tile, opening one panel with the sections as tabs (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)).\n- **Quick Edit** at top-right, 8px in, a STEP's or a robot's: the box of the note a\n  person writes to their agent, there only while something is picked, drawn or measured\n  (see [Quick Edit](#quick-edit)). The rest of the right side of the view is clear.\n- **View cube** at bottom-right, 2px from the right and 8px off the bottom so the axes\n  drawn in its lower corner never touch the edge (`VIEWPORT_CUBE_BOTTOM`), in a 6rem area:\n  enlarged face/edge/corner hit areas and neutral hover and XYZ guides. A host whose own\n  control floats over that corner lifts it clear with `--cad-viewport-corner-bottom`, the\n  room that control takes from the bottom edge. A host can instead anchor it at bottom-left\n  with `--cad-viewport-corner-left: 2px` and `--cad-viewport-corner-right: auto`.\n  Codex's tab presentation uses this for all its views to avoid the floating composer.\n  Preview omits it, and so does a phone.\n- **Display and Preview** end the navbar, for a 3D file (`data-view-controls`, the box\n  FileViewer hands the renderer as `navbarSlot`): **Display** (the active mode's sphere: see\n  [Display settings](#display-settings-and-section-primitives)) and **Preview** (a play\n  triangle, the way into Preview), the navbar's icon buttons, hinted below them. They are no\n  tools: pressing one leaves the tool in hand as it is. Display's dropdown opens down from its\n  button, aligned to its end, never taller than the viewer below it. A view that is not 3D has\n  none of them, and neither does a view drawn as a picture or one that is loading or failed to\n  load; in Preview the same place holds its own controls (see\n  [Preview](#camera-animation-and-preview)). With no navbar (a host showing the view\n  alone) they end the tool strip after its rule, and Preview's stand in the view's top-right\n  corner.\n- **Nothing sits at the bottom centre**, in the tools view or Preview: a file's routines\n  play from the file sidebar's Animation section (see [Preview](#camera-animation-and-preview)).\n- **Model update status** sits under the strip and its sub-toolbar, centred, and where\n  there is no strip (Preview, a compact view, a load) at the top centre, in the strip's\n  34px row, in every host. It is renderer chrome: the navbar carries no status. On mobile\n  it is a tappable progress icon whose popover names what is loading.\n\n## Tools and lifecycle\n\n| File | Tool strip, left to right |\n| --- | --- |\n| STEP | Select, Pencil, Shapes, Measure; Explode (two or more parts), Clip, Display and Preview |\n| URDF / SRDF / SDF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| GLB | Select, Pencil, Shapes, Measure; Explode (several rigid nodes that draw), Clip, Display and Preview |\n| 3MF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| STL | Select (in hand, the view's navigation: an STL picks nothing), Pencil, Shapes; Clip, Display and Preview |\n| DXF | Select (in hand, and the view's hand: a drag pans, a tap picks an entity), Pencil, Shapes, Measure; no view controls: a drawing is 2D |\n\nThere is no activity dot. A thin rule parts the tools from the view's controls, which\nare no tools (see [Ownership and layout](#ownership-and-layout)). Position is no tool:\nits knobs are Select's, while Select's filter picks Position, and its controls are a\nsection of the [file sidebar](#the-file-sidebar-and-the-tool-stack), and so is Animation, where\nthe file's routines play (see [Preview](#camera-animation-and-preview)). A file with no tools\nand no 3D view has no strip at all.\n\n**A tool the file cannot offer is not on the strip**: never shown disabled.\nRenderers leave it out of the `tools` they hand over; `FloatingToolBar` draws\nwhat it is given. Until a file has loaded, what it offers is not known: a tool\nthat depends on it (Explode) is shown idle meanwhile, a robot's filter offers\nPosition meanwhile, and the whole strip is disabled while loading. Unavailable and\nidle are different states.\n\nOne tool owns pointer input, and it alone is lit on the strip. An effect a person set —\nan explosion, a cut, a pose — stays on the model once its tool\nis put down, with nothing on screen for it but the model. **A press on the tool in hand\ndoes nothing**: a tool is put down by taking up another (Select, most often), or by Escape, which\ntakes Select up. A press on\nExplode or Clip with its effect set takes it up again, as it is; unset, it starts neutral.\n\n| Tool | Pressing it | Its sub-toolbar | Leaving it |\n| --- | --- | --- | --- |\n| Select | The default tool of every 3D file but an STL; picks what its filter ticks, or a GLB's nodes, a 3MF's objects (Shift adds; Delete deletes annotations and measurements); what is picked is Quick Edit's chips | None: its filter is the chevron after its button | The selection stays, lit and in Quick Edit, under any tool |\n| Pencil / Shapes | Draws on the view: Pencil with the pen, the highlighter or the eraser, Shapes with the rectangle, line, arrow, ellipse or text; each button is the tool last chosen in it | One row for both: the tools of the one that is up, then Color, Stroke width, Placement and Clear annotations (Undo and Redo are ⌘Z, ⇧⌘Z) | The annotations stay in the model, their chip in Quick Edit; each tool keeps what it draws with, the strip showing its icon |\n| Measure | Arms picking two entities and reading what the pair makes (a STEP's exact vertices, edges and faces, what its filter ticks; on a mesh, two points on its surface, approximately, in its file's unit; a drawing's ends, centres and lines, in the unit it declares, else \"units\"); each result a chip in its colour | Its measurements, as chips, then Clear measurements; none while nothing is measured | Unfinished picks are cancelled; completed measurements stay, drawn on the model, their row back with the tool |\n| Explode / Clip | The effect as it is set, or neutral; an edit applies it (Explode's, as a motion: see below) | The slider, its amount beside it, Reset (Clip's: its axis first, whose dropdown holds its settings) | The effect stays on the model |\n\n**One motion at a time.** Every tool's state stands beside every other's — the selection,\nmeasurements, the annotations, Clip — except the two that move the parts: **Explode** and\nthe **Position** pose. They replace each other: taking up Explode puts the pose back at its\ndefault, and using Position (a knob dragged, a value set or a named pose chosen) takes\nExplode off, with nothing set aside to come back. While the model is exploded there are no\nknobs. Put down, Explode keeps its explosion under any other tool until Position is used. A\nroutine played from the Animation section is the third motion: playing it puts Explode and the\npose back at rest, and using either puts the routine back at rest; paused, its frame stays on the\nmodel as a pose does. Preview's routine is its own and moves nothing of the tools view's.\nAnother file has the motions its tools have, under the same rule, decided by value\n(`kit/tools/model-effects/motionExclusion.js`): a GLB's Explode, routine and Clip; a robot's\nPosition and Clip.\n\nA tree is the file sidebar's, on screen under every tool; a pick in it takes Select up\nbefore it acts.\n\n**Removing an effect.** Explode's and Clip's sub-toolbars end in\nReset (\"Reset explode\", \"Reset clip\"): always there, disabled while there\nis nothing to take away, it removes the effect — the amount back at zero, nothing else (Clip's\naxis and its settings stay as set) — and leaves the tool up. Position's way back is its Pose row's Default. A\nmeasurement's chip X deletes it, and Measure's Clear measurements all of them; the Annotation chip's X clears the annotations.\n\n**Select and Measure are the tools with a menu on the strip** (Measure's on a STEP only), each its selection filter: the\nchevron right after the button (`ToolSettingsMenu`'s \"chevron\" trigger, `kit/tools/ToolModeMenu.jsx`),\nas tall as the tile and touching it. The button and its chevron are **one control**\n(`FloatingToolBar.js`): one rounded fill lights the pair on hover, while the tool is in hand and\nwhile its menu is open, and the chevron's half only brightens over it, on its own hover and\nwhile its menu is open, with no line between the halves. A change\nto a filter takes its tool up. A press on any other tool is its only action. Whatever a tool can\nbe set to while it is up is its sub-toolbar. A dropdown is ordinary — under its own button,\nfree to overlap the view — and closes with no exit animation, so a quick second tap (touch\nincluded) always reaches its trigger. Choosing a value closes it; ticking an option leaves\nit open, as do Clip's axes and the app menu's Theme. Menu checks sit on the right. The other dropdowns over the viewport are Display,\nthe sub-toolbars' (Clip's axis and settings, the drawing tools' colour, stroke width and placement), its\ncontext menu, and the Animation section's routine and Playback settings (`PlaybackMenu.jsx`, on\n`ToolPopover`). Preview's settings are a panel, not a dropdown (see [Preview](#camera-animation-and-preview)).\n\n**Select's menu** (`kit/tools/select/SelectFilterMenu.jsx`) is a CAD tool's selection\npriority (Fusion's Select > Face Priority, Blender's vertex, edge and face modes), not a\nchecklist: the chevron's dropdown, headed \"Pick\", lists ONE choice of what a click on the model\ntakes, each with its glyph — **All** first, the default, then each kind alone. Choosing\none puts the menu away and takes Select up. A STEP's (`SelectionModes.jsx`'s `stepSelectModes`):\n**All** (the pointer: a face or an edge where a part's are loaded, the part elsewhere,\nPosition's knobs, the annotations and the measurements), **Parts** (a whole cube; an assembly's\nonly — in a part file a press that reaches no face or edge picks the whole part), **Faces** (a\nfaint cube, its top face filled), **Edges** (a faint cube, one edge heavy) and **Position**\n(where the file has joints to move: its knobs alone, the model picking nothing). A robot's:\n**All**, **Links** and **Position** (where a joint can be driven; a robot with nothing to\ndrive has no menu). The annotations and measurements are no choice of their own: they are drawn\nover the model, so a click on one is never taken for the face under it, and a kind alone (Faces)\npicks that kind only. All is chosen when a file opens; the choice holds while the file is\nopen, across updates of it, and another file starts on All. The chevron's name says what\nis chosen (\"Selection filter: everything\", \"Selection filter: Faces\"), and Select's button wears\nit as a badge on the pointer (`modeGlyphs.jsx`'s `ToolModeIcon`; none for All), so what a\nclick takes is on the strip, not only in the menu. After a rule, a STEP's menu ends in how a face\nor edge pick grows, each with its glyph and independent of each other: **Group edges** (the\nconnected edges with a picked edge), shown while edges are picked, and **Group faces** (the\nsmoothly joined faces with a picked face), shown while faces are; a hidden one keeps its choice\nfor when it shows again.\n\nThe geometry chosen is picked as one mode (`step/workbench/selectionFilter.js`'s\n`selectGeometryMode`): All, a press takes the face or edge it lands on where the part's\ntopology can be picked, and the part elsewhere; Parts, the part; Faces or Edges, that topology\nalone. The mode sets the model tree's shape: under All, or Position (no geometry at all),\nit is the person's own (put back as it\nwas when they left it, with the owners of what is still selected kept open); Parts alone\nopens every assembly and shuts every part; Faces or Edges open every assembly. A\npart is a leaf in every shape: nothing opens under it, and a face or edge picked in the\nviewport is revealed at its part's row. Outside the person's own shape the disclosure is\nlocked (chevrons shown, not pressable) and Expand/Collapse leave the row menus. Under Faces\nor Edges a part's topology is asked for as its row comes on screen in the tree, never for a\nwhole large assembly at once; a viewport press on a part not yet loaded loads that part and\npicks, and the model tree's heading says \"Loading…\" meanwhile. In a large assembly (more than 300\nrows of assemblies and parts, `LARGE_TREE_ROWS`) a part's row coming on screen asks for\nnothing: the pointer resting on the part in the viewport (150ms) or pressing it asks.\n\n**Sub-toolbars** (`kit/tools/SubToolbar.jsx`): the tool in hand's controls, one row hung\nunder the strip, centred, 8px below it, on the strip's surface, its buttons the strip's\n24px tiles, its groups — what the tool does, then how it looks and what clears it — parted by a\nthin rule. It stays up for as long as its tool does, and has no heading: the tool's lit\nbutton above names it. Explode's and Clip's are one shape — what leads\nthe slider, the slider, its value right beside it and only as wide as\nits text, the tool's actions (neither has any), then Reset, last, at the same spot in both,\nwith no rules — and one width (`SLIDER_SUB_TOOLBAR_CLASS`, 202px; a phone's 320px). The slider\ntakes the rest of the row. A tool's options never sit in the row as checkboxes, labels or\nbuttons of their own, which would squeeze the slider: they are the dropdown of what leads it\n(Clip's axis).\n\n**Pencil and Shapes.** Drawing is two plain strip tools, a button each, with no chevron and no menu:\n**Pencil**, drawing with the **Pen** (the freehand ink, tapering with pressure or\nspeed), the **Highlighter** or the **Eraser**; and **Shapes**, with the **Rectangle** (what it opens on), **Line**, **Arrow**,\n**Ellipse** or **Text** (a mark put down in place). The tool's button shows the icon of what it draws with\nnow, and a press takes up the one last chosen. Pencil and Shapes share the annotation and its history, whichever is up, but not the style: the colour, stroke width\nand placement are kept **per drawing kind** — the Pen, the Highlighter (which opens in neon yellow) and Shapes (all five\nshapes share one) — and the row shows and sets the tool in hand's own — and **one sub-toolbar**,\n\"Annotation controls\" (`annotation/toolbar.jsx`'s `AnnotationToolbar`), a single row, never a\nsecond, from the left: the **tools of the one that is up**, a button each, the one in hand pressed (with Pencil up, Pen,\nHighlighter, Eraser; with Shapes up, Rectangle, Line, Arrow, Ellipse, Text: choosing one is how the strip tool changes what it\ndraws with); a thin rule; then what is drawn next, each a dropdown whose button shows its value, no\nchevron: **Color** (no heading, a grid of swatches, unnamed but for assistive technology: the neons and\none neutral that reads against the viewer, white in dark mode and near-black in light, then\nthe colour wheel, which opens the system's colour picker for any other colour, kept once the\npicker closes), **Stroke width** (no heading, four weights, 1, 2, 4 and 6px, the pen drawn to look the\nsame weight; on the button and as each row, a straight line drawn at that weight, unnamed but for assistive\ntechnology, the current one checked) and **Placement** (laid out as the strip's menus are: headed with its name, a\nrow per choice, the check on the right: **On surface**, the default, its glyph a sphere: each point of the next mark on the visible\nsurface under it; **On plane**, its glyph a sheet: the whole mark on the plane of the surface it\nstarts on; a flat view, a DXF, has none); then **Clear annotations** (the trash), disabled while there is\nnothing to clear. Under the **Eraser**, which draws nothing, Color, Stroke width and Placement are disabled (greyed out, still\non the row so it does not jump). Undo and Redo have no buttons: they are ⌘Z and ⇧⌘Z (Ctrl+Z, Ctrl+Y). The row is the same row for both tools at every width; on a phone its\nbuttons are 36px tiles and, where the row is wider than the view, it wraps rather than scrolls (there is no second menu on\nthe strip for it). The **Highlighter** draws a freehand stroke like the pen's, but broad and translucent: five times\nthe chosen stroke width, 35% opaque, round at its ends, with no pressure taper; it is an ordinary stroke, picked,\nerased and recoloured as any (its Stroke width in the selection's bar is the same setting). The tools have no Select or Pan of their own (Select picks\nannotations, a right drag or Space orbits). Never a restyle of ink already down: the\nannotations Select picks have their own bar, a small floating toolbar on the tool strip's\nsurface just above them — Color and Stroke width dropdowns, Fill for a closed\nshape, a rule, Delete. An update of the model forgets the annotations too, their history with\nthem (Undo has nothing to bring back), and leaves the tool in hand, with its tool, colour,\nweight and placement. The pen and the shapes share one default stroke\nwidth. The annotations have no panel: while there are any they are Quick Edit's\n**Annotation** chip (its X clears them), and while Pencil or Shapes is up the copy shortcut writes\nthe view with its annotations to the clipboard as a PNG. A first stroke opens\n[Quick Edit](#quick-edit), whose header says **annotation** while there is ink. The drawing tools\ndisable the cube without hiding it.\n\n**Measure.** Its button is the ruler, badged as Select's is with what it\npicks alone. On a STEP, its menu is Select's own (`kit/tools/measure/MeasureFilterMenu.jsx`):\n**All** (when a file opens), **Vertices**, **Edges** or **Faces**, one chosen. A press takes an\nENTITY, whole, as a CAD measure tool does: an edge's end within 10px of the pointer is its\nvertex, else the edge, else the face (`measureFilter.js`). What lights under the resting pointer\nis what a press takes, with what it is worth on its own beside it (a vertex's X Y Z, an edge's\nlength or ⌀, a face's area). With one kind chosen, only that kind is taken: an edge's end is still\nits vertex under Vertices, and anywhere else a press takes nothing. Shift held, a press takes the\npoint on the model as it is. In an assembly, a press on a part whose faces and edges are not loaded asks\nfor them and takes the pick there once they arrive.\n\nThe reading follows from the pair (`@text-to-cad/core/lib/viewer/measureEntities.js`), not from\nwhere on each the pointer happened to be:\n\n- two vertices: the distance, with ΔX ΔY ΔZ;\n- parallel faces, parallel edges, a vertex to a face or an edge: the perpendicular distance, \"⟂\";\n- a hole (a circular edge or a cylinder) to anything: from its centre or axis, so two holes\n  read \"C-C\" and a hole to a face its distance from that face;\n- faces or edges at an angle: the angle, \"∠\", the acute one;\n- anything else: the shortest distance between the two.\n\nA pair with no gap (an edge in its face, two flats at one height) reads as the distance between\nthe two points picked. A press on the entity already held lets go of it, as does a press on\nnothing. On a mesh there are no vertices, edges or faces to take: a press takes the point on its\nsurface under the pointer, and the menu goes. A drawing's Measure has no filter.\n\nIts sub-toolbar is its measurements (`kit/tools/measure/measurementChips.jsx`'s `MeasurementsBar`),\nthere while Measure is the tool and something is measured, and no row at all while nothing is:\neach completed measurement a chip, Quick Edit's own (`ReferenceChips.jsx`, a few then \"+N more\"),\nthe ruler in the measurement's colour (the colour of its line on the model) and its reading; its\nX deletes it, resting on it lights its line and labels it, and its card, opening under it, is the\nfull reading (what the pair makes, the angle or the shortest distance with it, the delta along\neach axis between two points, what each end bound to); then a rule and **Clear measurements**.\nMeasurements are the person's alone: none of them goes to the agent, in Quick Edit or its\nsnapshots, and measuring never opens Quick Edit. They stay, drawn on the model, under every other\ntool; a press on Measure while it is up does nothing.\n\n**Explode and Clip** have no enabled checkbox, and their sub-toolbars are drawn alike.\nExplode's: its slider, the amount (a percentage), **Reset explode**. Clip's: its axis — a\nborderless button reading the axis in hand, \"X ⌄\", lit while its menu is open — its slider, the\namount, **Reset clip**. The axis's dropdown holds all of Clip's settings, on Display's surface\nand in its type, as narrow as its content, with no heading and no glyphs: first X, Y and Z, a\ncompact group of the app menu's Theme buttons (24px squares, its radius, no track) in a row as\ntall as an item (a phone's 40px, as Theme's row), the axis in hand in the accent a highlighted\nitem wears; a rule; then two of Display's `DropdownMenuCheckboxItem`s, plain text with the check\nat the right: **Part colors** (ticked by default: each section cap in its\npart's normal colour, without hover or selection tints; unticked, every cap one amber) and\n**Reverse** (unticked by default: which side of the plane is kept). Caps stay opaque, a\ntransparent part's too. A press on any of them leaves the menu open, and none of them applies a\ncut; on a cut, a new axis or Reverse turns it keeping its share. They are settings of the\nfile's view (`clip.axis`, `clip.partColors`, `clip.invert`, stored and restored with its\ndisplay), kept across tools, by Reset clip and by taking up an unset Clip. Explode opens at 0%, Clip at no\ncut; an edit applies the effect, and the slider back at zero removes it. An axis alone is not\nan effect. The view settings own both effects, so a mode reset preserves them and a restored\neffect reappears on the model. Clip's slider, left to right, cuts deeper through the original\nbounding box; pose, animation and Explode never redefine the range. It shows the share cut\naway, while the view stores where the plane stands (`clip.at`, in millimetres), so a model\nedit leaves the plane where it was and the slider reads 0% or 100% if the plane ends up\noutside the new box. Explode is offered only where there are parts to separate: a STEP\nassembly and a GLB of several rigid nodes. A STEP part file has no Explode tile, as it has no\nModel tree (`STEP_PART_VIEW_FEATURES`).\n\n## The file sidebar and the tool stack\n\n**The file sidebar** (`kit/tools/FileSidebar.jsx`) holds what there is to read of the file,\nwhatever tool is in hand — the strip is for using tools, the sidebar for reading the file: a STEP\nassembly's **Model tree** (a part file, whose tree would be its one row, has none), then\n**Position**, where the file has joints to move; a robot's **Links**, an `.sdf`'s **SDF**, then\n**Position**, where a joint can be driven; a drawing's **Layers**, where it has four or more\nlayers or any block reference (a flat pattern's CUT and BEND has none): each layer's pen, name,\nentity count and eye, its block references under it; and, after them, the shell's own **Animation** for a\nfile with routines (`kit/tools/playbar/AnimationSection.jsx`). A file with none of them has no\nsidebar. It is a toolbar of its own at the view's top-left, 8px in, on the strip's line and of\nits surface, padding and 24px tiles (`data-file-sidebar-tools`, \"File sidebar\"): one tile per\nsection, a tree's a tree icon named for it (Model tree, Links), Position's a spline, Animation's a\nclapperboard, any other section's a page named for it. Preview offers its Position and\nAnimation alone, their tiles bare in its top-left corner; the rest stay mounted, out of sight.\n\n- **One section at a time.** None is open as a file opens. A tile opens its section in a panel\n  under the toolbar, 8px below it, and is pressed while it is open; another tile opens its\n  section in the panel instead; a second press of its tile closes it, and so does the X that\n  ends the section's head. The panel slides in from the left as a section opens with none open\n  before, and back out as the last closes (200ms, none under reduced motion); from one section to\n  another it stays where it is. Closed, the panel is out of reach (`inert`). Every section stays\n  mounted, open or not, so a tree keeps its expansion, search and scroll. Nothing folds: a\n  section is its head and its body.\n- **Sections.** Each section is a `ToolPanel` drawn as one: a 24px head — its name 12px in from\n  the panel's edge, what its body puts there (`ToolPanelHeadAction`: Position's and Animation's\n  Reset), then its X — and its body. A tree's head is its filter instead of its name, the X\n  after it. The section is its content's height and the panel as tall as it is, up to its cap;\n  past that the whole panel scrolls, as one, the head pinned to its top\n  (`data-pinned`). A tree's rows are mounted as that scroll brings them into view\n  (`kit/inspector/VirtualRows.jsx`).\n- **Filter.** A tree is headed by its filter (\"Filter model\", \"Filter links\": \"Filter…\"), in place of a title,\n  always there, a tree row's 24px and 11px text, its icon over the rows' glyph column\n  (`primitives/tree-filter.jsx`'s `TreeFilterInput`, `dense`), its X clearing it once something\n  is typed, Escape too. The model tree's filter is followed by \"Loading…\" while a part's faces load\n  and **Show all** while parts are hidden. Every tree's search is one ranked flat search\n  (`kit/inspector/modelTreeSearch.js`).\n- **Size, each section's own.** A section's panel opens a tool panel's width\n  (`TOOL_PANEL_WIDTH`, 164px) and as tall as its section, capped at the room under the toolbar.\n  It is sized from its bottom-right corner alone, by Quick Edit's grip (`kit/tools/ResizeGrip.jsx`):\n  width and cap together, by pointer, or by keyboard (a separator named \"Resize file sidebar\":\n  Left/Right nudge the width and Up/Down the cap by 16px, Home and End take both to their\n  bounds). Its width runs from 120px (`FILE_SIDEBAR_MIN_WIDTH`: room for a heading's name and its\n  buttons; a tree's names truncate) to half the viewer, its cap from 64px to its room. One write,\n  when the gesture lets go (or per key), never per pointer move; each section keeps its own size\n  (`sidebar:<id>`), so the model tree may be wide and tall while Position stays narrow.\n- **On a phone** the sidebar is one touch tile, **Sidebar** (a panel icon,\n  `data-file-sidebar-toggle`), at the left of the strip's line, the strip padded clear of it on\n  both sides and keeping to its tools (`FileSidebar`'s `phone`). It opens one panel on the section\n  last open (the first, at first): its top a row of tabs, one per section with its tile's icon\n  (shadcn's `ToggleGroup`, `data-file-sidebar-tab`), and the panel's one X at that row's right end\n  (a section's head then has none; a panel of one section has no tabs and keeps its head's X).\n  The panel runs down to the view's bottom inset, or is as tall as its section if that is less,\n  stopping above Quick Edit's peek (`--cad-quick-edit-peek`), at a phone's own width (288px,\n  within the view's inset), with no grip. Its text is the panel's one size (13px, as the\n  Display menu's), its tree rows 36px with 16px glyphs, its filter, dropdowns and value fields\n  32px (`globals.css`). In Preview its tiles are its top-left's, as on a desktop.\n- **The layout is the person's.** The section sizes are one of the tab's settings, across its\n  files (`CadPreferences.toolStack`: `{ panels: { [id]: { width?, height? } }, collapsed: { [id]: boolean } }`,\n  a section's panel under `sidebar:<id>`, kept beside the appearance; any other key a stored\n  record carries is dropped). `panels` holds only what a person set; `collapsed` is the tool\n  stack's folded panels. Which section is open is not kept: a file opens with none. A new tab (a\n  cleared record) puts everything back at its defaults.\n\n**The tool stack** (`ToolStack`) is the column under the strip and the sub-toolbar, centred\nwith them, for a renderer's own `toolPanels`. STEP and the robot renderer hand none: their\ntools' controls are sub-toolbars and what there is to read of their files is the sidebar's.\nIts panels follow these rules:\n\n- **Two kinds of panel.** A `resizable` panel is the person's to size, on its own; every\n  other panel is *fixed*: one width, its content's height, and no grip.\n- **One width.** Every panel opens at `TOOL_PANEL_WIDTH`: 164px, a strip of six tools (six\n  24px buttons, 2px gaps, 4px padding and a 1px border). A fixed panel is exactly that wide; a\n  resizable one is only ever made wider, up to half the viewer. The panels hang left-aligned\n  in the column, each at its own width. Content truncates to fit; it never widens a panel.\n- **Heights.** A panel is exactly its content's height — never padded to a minimum. A\n  resizable panel has a *cap* the content grows up to and then scrolls inside: it opens capped\n  at the stack's own height (`toolPanelDefaultHeight`). A cap is never a floor.\n- **One grip, on a resizable panel only**, the sidebar's: its bottom-right corner, Quick\n  Edit's grip, moving its width and cap at once, a separator in the tab order named after its\n  panel, keyed as the sidebar's is. A folded panel has no grip: there is no height to set.\n- **Never past the viewer.** The column stops 8px above the cube's line\n  (`VIEWPORT_STACK_BOTTOM`, `calc(6rem + 16px)`). When the panels need more, a `\"tree\"` panel\n  gives way first, down to 128px or its content, then the `\"details\"` panels together, each in\n  proportion to its height, down to 96px or its content; a `\"fixed\"` panel keeps its height.\n  If what cannot give way still does not fit, the column itself scrolls — a panel is never cut.\n- **Headings.** A panel's heading row reads as Display's section headings do (11px, regular,\n  `TOOL_PANEL_HEADING_TEXT_CLASS`), 28px tall, 8px in: a title, a summary where there is one,\n  then at its right end its own actions, the chevron where it folds (up while open, down while\n  folded) and an X where it has something to remove. Every small button in a heading is 20px,\n  2px apart and 4px from the edge. A panel can end in one full-row button under its body,\n  which never scrolls (`footer`, `ToolPanelFooterButton`).\n\n- **Surfaces.** Three, defined once (`lib/floatingSurface.js`), with one border: the\n  tool strip, its sub-toolbars and the stack's panels, which stay up beside the model, share\n  `FLOATING_CHROME_SURFACE_CLASS` — the background at 30% and barely blurred\n  (1px), so the model behind them is easy to make out; every menu and popover\n  over the viewport, the app menu and Display included, share `FLOATING_SURFACE_CLASS` (the\n  background at 75%, blurred), so their text never competes with the model; and the file\n  sidebar, mostly text but up for as long as the file is open, is between them\n  (`FILE_SIDEBAR_SURFACE_CLASS`: 55%, a 4px blur).\n- **Scrolling.** Every scroll region in the viewer's chrome — the file sidebar, a panel's\n  body, the stack's column, the explorer, a menu, the alert card — is the\n  `ScrollArea` primitive (`primitives/scroll-area.jsx`, shadcn's): thin overlay\n  bars in the theme's colours, shown while the pointer is over the region. No\n  native `overflow-auto` scroller in chrome; `src/designSystem.test.js` holds it.\n  The home (the model library) is a page, not chrome: it scrolls as a page does,\n  with the platform's scrollbar, and only when there is more of it than fits.\n- **Nothing opens elsewhere.** A pick shows as Quick Edit's chips; the sidebar is on screen\n  under every tool. No pick or tool opens or closes the explorer, on desktop or mobile.\n\nTree rows inset their backgrounds 4px from the panel's edges, and the search box\nshares that inset. A tree in the file sidebar (Model tree, Links) is dense (`TreeRowSurface`'s\n`dense`): 24px rows in the panels' 11px text, and ONE light 12px glyph per row in one\ncolumn, as the explorer has: a subassembly's or folded group's disclosure chevron, turned down\nwhile open, or a part's box (Links draws its chevrons alone), 4px before the name. The glyph\ncolumn starts 5px in from the row's left end (`TREE_ROW_INSET_PX`), centred under the find\nfield's search icon and as far in as the icons of a row's actions sit from its right end, in the\nSTEP tree and the explorer alike; each level adds\nthe explorer's compact indent, 10px (`TREE_INDENT_PX`), its rows hung from a faint line under\ntheir owner's glyph (`TreeRowGuides`), as the explorer's are; its search box is its section's head. An assembly row's\nactions, shown on hover and kept while they are on, are **Isolate** then the **Hide/Reveal** eye; a part file has no\nIsolate. A Links row that draws something — a link with visuals, or one visual —\nhas the eye alone, in its search hit too: a link's hides its own visuals, never its\nchild links, and on a partly hidden link hides the rest. They float over the row's right end rather than taking width from it:\nthe name runs the row's full width and, while an action shows, fades out half a\nrem before them (a mask, `ROW_NAME_UNDER_ACTIONS`), so nothing is drawn behind the\nbuttons and the row keeps its own colour.\nModel and link finds and the explorer's filter share `TreeFilterInput`; the explorer's rows are compact\n24px lines, as the trees' are, but in 12px text.\n\n**Mobile** is below 600px of FileViewer width (Material's compact window class) — the one viewer breakpoint\n(`useViewerMobile`); chrome never uses window breakpoints, nor the pointer's kind: a small window\nis worked as a phone is. What a finger reaches is the gesture's own (`pointerType`): a touch takes an\nedge from 20px off (an edge within 16px wins over the face under it), ink, a measurement and the\neraser's ring from 16px (`TOUCH_HIT_PX`), and a joint's knob from 22px; a mouse keeps its few px.\nOn a phone every slider is a 36px band with a 20px thumb, and the selected ink's bar has 36px\nbuttons. A pinch zooms at the touch speed from the first gesture after a load. A phone is read at arm's length\nand worked with a finger, so its chrome is one step up the scale, after the platforms' own\nguidance (Apple's 44pt and Material's 48dp touch targets, an 11pt type floor and 15-17pt body\ntext, and iOS Safari zooming into any field under 16px): type 12, 13, 14 and 15px for `text-micro`,\n`text-tiny`, `text-xs` and `text-ui`; text entry 16px; menu, select and tree rows 36-40px; the\nnavbar a 48px row of 36px controls; the strip and the sub-toolbars 36px tiles (the strip one row,\n**More tools** holding what does not fit; the drawing tools' row the same single row, wrapping where it must).\nThe scale is set once, in `globals.css`, for the whole page while a phone's view is on it, so a\nportalled menu, select or hint is the same size as the chrome it opened from. There is no view\ncube; the file sidebar is one tile at the strip's left, its sections tabs of one panel; and\nQuick Edit is a bottom sheet (see\n[Quick Edit](#quick-edit)). The explorer is the same popover, never wider than the window less\n1rem. The shortcut hint is hidden.\nTouch works for every tool: one finger orbits, two pan and\nzoom, a tap picks; a pinch, a cancelled pointer or a camera drag is never a\npick.\n\n## Display settings and section primitives\n\nDisplay is not a tool. Its tile ends the strip after the strip's rule, between Projection and\nPreview, and shows the current mode's sphere icon. It opens a stock shadcn dropdown menu\n(`DisplayPopover.jsx`): a muted **Display** label, the modes as a `DropdownMenuRadioGroup`,\na separator, then **Grid**, a `DropdownMenuCheckboxItem`. On a phone the strip's **More**\nmenu has a Display row whose nested submenu is the same list (`DisplayMenuItems`), with no\nlabel. Checks sit at the row's right end. Choosing a mode closes the menu; ticking Grid\ntoggles it and leaves the menu open (`onSelect` prevents the close). Clicking outside or\npressing Escape closes it. Opening it leaves the active tool and its controls in place. A\n2D view has no Display or Preview control.\n\nDisplay is a choice of mode and the Grid switch, and nothing else: a mode has no settings\nof its own, and there is no settings page. The modes, in order, are **Solid** (shaded with\nthe model's CAD edges, the default), **Shaded** (no edges), **Parts** (Solid with each part\nits own colour), **X-ray**, **Hidden line** and **Wireframe**, filtered by the view's\ninjected capabilities (`viewFeaturesForKind`, `DisplayModeOptions.js`): a STEP assembly\noffers all six, a STEP part file all but Parts, and a file without CAD edges (STL, 3MF, GLB,\na robot) Solid, with Parts when it has several parts, objects or links. **Grid** draws the\nmeasuring grid in every mode, off by default. Each row has its icon, the sphere drawn as the\nmode draws a model (and a tile of floor grid for Grid). Render is no display mode: Preview\ndraws the model in Render whatever the file's mode, with its own settings (see\n[Preview](#camera-animation-and-preview)). Per-file storage keeps the mode and the grid;\nold-shaped stored display settings are dropped setting by setting, never migrated.\nProjection is a menu of its own, shared, orthographic by default; Preview's Render resolves\nto perspective while preserving the shared choice.\n\nPreview's settings panel (`PreviewSettings.jsx`, its body `PreviewRenderSettings.jsx`) is\nthe one settings form: Orbit, then Render's Part colours, Lighting, Background and Floor.\nIt shows all groups under static section headings and dividers, with no collapsible\nsections or cascading settings menus. A heading is 24px of regular `text-tiny` in the\nforeground colour, a rule above every one but the first. A feature that switches on and off\n(Orbit, Floor) is the checkbox at its heading's right end, the title naming it; there is no\nseparate row for it. While the feature is off its section is that heading alone. Every\nsetting is one 24px row: its label in a 4rem muted column, its control filling the rest, so\na form's controls start on one line. A label is short under its heading (\"Colour\",\n\"Opacity\" under Floor); the control's accessible name stays whole (\"Floor opacity\"). A\ndropdown or colour is as wide as its value, at the row's right end. Sliders are shadcn's,\nas everywhere, with a committed value field after them. By part's palette is a row of\nswatches, then add and remove. Choices use Select controls. Continuous values use compact\nslider and committed-number fields; their keyboard input stays with the field. Colours\nretain swatches and a custom colour picker. Floor is on by default, at the model's Z=0\nplane. A long form scrolls within the available viewer height. A nested Select or colour\npicker owns Escape and outside interaction before its parent surface. The last ite\n\n<!-- [TRUNCATED AT 50KB] -->","packages/ui/docs/view-updates.md":"# Responsive View updates\n\nView controls always edit the per-file `viewSettingsStore` immediately. That\nstore is the only settings authority. A mode is a preset batch; the renderer\nnever branches on its name. Rendering completion cannot rewrite controls,\npersistence, feature gates, or the Custom label.\n\n`kit/view-settings/viewUpdateCoordinator.js` owns one active operation and one replaceable\nrequested scene recipe. `kit/view-settings/useAppliedViewSettings.js` binds it to React.\nRapid edits replace pending work; they do not accumulate in a queue. Preparation\nis cancellable. Once scene reconciliation starts it finishes serially, but a\nsuperseded result is not presented. File changes/unmount cancel work and reject\noutstanding captures. Errors leave controls editable and expose Retry.\n\n## Cost policy\n\n`kit/view-settings/viewUpdatePlan.js` is the single classification point. Compare resource\nboundaries, never preset names:\n\n| Preparation | Live update |\n| --- | --- |\n| Lighting enable, quality, softbox size/fill | Exposure, rotation |\n| Surface style/color source, crossing opaque/transparent | Surface color, opacity within one transparency class |\n| Edge enable/visibility | Edge color |\n| Floor enable | Floor color, opacity, placement |\n| Clip enable/disable | Clip position, Reverse and Part colors after preparation |\n| Appearance shader change | Projection, background, grid, axes, Explode |\n\nBoth paths yield until the controls have painted, then apply the latest recipe.\nOnly the preparation path loads resources and compiles shader variants. Live\nupdates coalesce to the next frame and do not wait for an artificial debounce.\n\nThe Clip and Explode tool buttons add neutral control panels. Editing a slider or\nnumber enables the effect; X or pressing the tool again resets and removes it.\nClip uses original model bounds for its slider and the rendered plane, independent\nof pose and Explode. Its neutral endpoint is the maximum coordinate (minimum when\nflipped). It has one active axis and plane, exposed in its panel as a compact X/Y/Z\ndropdown and a single cut-amount slider. The slider starts neutral on the left and\nadvances from maximum to minimum coordinate; a flipped cut (`invert`) reverses that direction. CLI coordinates, including zero, remain supported. Clip and Explode stay outside mode presets.\n\n## Renderer and resources\n\n`kit/look/studioEnvironmentCache.js` keeps at most three reflection maps per\nviewport. `studioEnvironment.worker.js` generates PMREM in worker WebGL and\ntransfers half-float pixels; model geometry stays on the main renderer. Rotation\nand exposure reuse the map. Superseded work terminates the worker; completed\nmaps are reused by key. Workers terminate after transferring their pixels, so inactive tabs do not\nretain extra WebGL contexts. Disposal releases textures and any pending worker. Browsers\nwithout OffscreenCanvas/Worker use a synchronous compatibility path after the\ncontrols paint; the full responsiveness benefit requires worker WebGL.\n\n`kit/view-settings/viewUpdateGate.js` preserves the previous canvas framebuffer while the\nscene is reconciled and Three's `compileAsync` prepares its programs. The\nrenderer/context and cached geometry stay mounted. `viewportBuffer.js` queues\ncanvas resolution changes and flushes them inside the next actual draw callback.\nChanging DPR or dimensions outside that callback clears the retained frame, so\nno quality effect or resize observer may resize the backing buffer directly. A viewport\nresize DRAWS that frame instead, synchronously inside its ResizeObserver callback (after\nlayout, before paint): the canvas is sized 100% by CSS, and a frame requested from there\nwith `requestAnimationFrame` would land a frame late, painting the old picture stretched\nover the new box whenever the viewer changes width in one step (a window snap). That synchronous frame replaces any queued one, so a drag still draws once\nper frame, and an idle viewer draws nothing. While the gate holds, the draw is skipped\nlike any other, the buffer keeps its size, and the held picture waits for the frame that\nreleases it (`kit/viewport/ViewportResize.browser.test.mjs`). Presentation resumes after\npreparation; completion is acknowledged after an actual draw. Camera input is\naccepted during preparation, though the displayed pose can briefly wait while\nthe scene is held. Shader support and GPU drivers can still impose synchronous\nwork (notably shadow allocation/first draw); this is not a second renderer or a\npromise that all GPU work is preemptible.\n\nScreenshot/prompt capture awaits the coordinator's latest presented revision,\nso it cannot accidentally capture a half-applied recipe. Ordinary snapshots\noutside FileViewer retain their own scene setup lifecycle.\n\n## Status placement\n\n`kit/status/ViewUpdateStatus.jsx` only takes `status`, `onRetry`, and\n`className`. It has no scene/store knowledge. RendererShell draws it centred under the tool\nstrip and the tool in hand's sub-toolbar, or at the top of the viewport in the strip's row\nwhere there is no strip (preview, a load), without changing scheduling. It appears after 150ms with “Updating view…” (or\n“Preparing section view…” for Clip), stays nonmodal, and never disables controls.\nFast changes produce no spinner. Failures show a retry action.\n\nTests cover latest-request wins, cancellation back to the visible recipe,\nserialized reconciliation, failure/retry, capture readiness, disposal, and\nStrictMode/control independence. Real browser checks must also exercise cold\nRender, rapid preset/clip edits, a completed capture, and a warm return.\n","packages/ui/docs/viewer-host.md":"# Viewer host contract\n\n`FileViewer` requires one explicit `ViewerHost` from `@text-to-cad/ui/host`.\nShared UI implements rendering and document interaction; apps supply environmental\neffects. No shared feature discovers Electron, browser clipboard, a backend URL,\npersistent storage or page navigation. DOM, canvas, workers and layout remain\nshared. Missing optional methods mean an operation is unsupported.\n\nThe host contains `files`, optional native `fileActions`, `clipboard`,\n`promptContext`, the optional `promptFiles`, `navigation` (the optional `openFile(path)` and\n`home()`), the optional `links` (the app menu's), the optional `usage`\n(`used(feature)`: what the person used, for the host's telemetry to count, never what\nthey made; absent, nothing is counted) and `environment`. `environment` carries the resolved `colorScheme`,\nthe resolved `locale` (the language the view reads in: the person's choice, else what the\ncontext infers, as `@text-to-cad/ui/i18n`'s `useLanguage` resolves it; none, or one the viewer\ndoes not speak, reads English),\nthe keyboard `platform` (`darwin` shows ⌘, anything else Ctrl), the app's own\n`reducedMotion`, honoured beside the system's `prefers-reduced-motion`, and\n`compact`: a view drawn as a picture (the home draws its thumbnails this way, out\nof sight), where a CAD renderer draws the model alone — no tools, view actions,\nview cube or Quick Edit — and FileViewer draws no navbar. A view shown small in a\nconversation is not compact: it is the whole viewer, and its host hands it a way\nto full size as `fullSize` (`CadViewerProps.fullSize`), among the last of the navbar's\ncontrols and the home's, before the host's colour scheme control. CAD is a separate registration supplied with a\n`CadWorkspaceService`; the generic FileViewer does not import CAD. The HTTP CAD\nadapter can serve both apps, while desktop owns native runtime startup/recovery.\nSee [workspace resources](../../core/docs/workspace-resources.md) for resource\ntickets and cache identity.\n\nA host passes no tolerance to the STEP renderer: it draws each part at the display mesh its\npart's record names, which cadgen derived at its display tolerance\n([the display mesh](loading.md#1-the-display-mesh)).\n\nApps create services for the workspace lifetime. File tabs borrow them, while\nmounted renderers own scenes, document controllers and temporary resource leases.\nUnmounting one view releases its work without disposing another view's services\nor admitted cache writes. Hosts dispose a service when its workspace closes.\n\n## Interface map and implementations\n\nThe linked TypeScript definitions are the authoritative signatures. Import\ntheir compiled public package exports in application code; these source links\nare for reading and maintaining the contracts.\n\n| Contract | Definition | Public entry point |\n| --- | --- | --- |\n| `ViewerHost`, `ClipboardPort`, `PromptFiles` (a Quick Edit's views and annotations, saved as JSON), `usage` | [Host types](../src/host/types.ts) | `@text-to-cad/ui/host` |\n| `FileSource` (`stat`, and `list` and `search` where the host has them), `FileActions`, `FileViewerState` | [File viewer types](../src/file-viewer/types.ts) | `@text-to-cad/ui/file-viewer` |\n| `PromptContextPort` (`deliver`, `send`), bundles, references, delivery receipts and `formatPromptMessage` (the one message a Quick Edit is) | [Prompt types](../../core/src/prompt/types.ts) | `@text-to-cad/core/prompt` |\n| `CadWorkspaceService` (the catalog of the files on screen, `folder`, `search`), `createCadClient` (`{ origin, fetch, … }`), `isMissingFileError`, `CadResourceProvider`, worker tickets | [CAD service types](../../core/src/client/types.ts) | `@text-to-cad/core/client` |\n| `createHttpPromptFiles` (the viewer server's `PromptFiles`: `POST /__cad/prompt-files`, writes JSON and returns paths) | [Prompt files](../../core/src/client/promptFiles.js) | `@text-to-cad/core/client` |\n| `StepRendererOptions`, `CadLiveBinding` | [STEP registration](../src/renderers/step/index.ts) | `@text-to-cad/ui/renderers/step` |\n| `TabStore`, `TabRecordStorage`, `createTabStore`, `useTabViewerState` (the tab's one store: its settings, the view of the file on screen, and `FileViewer`'s state from both) | [Tab store](../src/tab-store/tabStore.ts), [the record](../src/tab-store/tabRecord.ts) | `@text-to-cad/ui/tab-store` |\n| `CadPreferenceSource`, `createCadPreferences` (the tab's settings as renderers read them) | [Viewer preferences](../src/renderers/workspace/preferences.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `DxfRendererOptions` (2D drawings; declines every camera, display and selection command) | [DXF registration](../src/renderers/dxf/index.ts) | `@text-to-cad/ui/renderers/dxf` |\n| `GlbRendererOptions`, `LiveViewBinding`, `LiveViewController` | [GLB registration](../src/renderers/glb/index.ts), [live binding](../src/renderers/kit/shell/liveBinding.ts) | `@text-to-cad/ui/renderers/glb` |\n| `MeshRendererOptions` (STL, 3MF), `LiveViewBinding`, `LiveViewController` | [Mesh registration](../src/renderers/mesh/index.ts) | `@text-to-cad/ui/renderers/mesh` |\n| `RobotRendererOptions` (URDF, SRDF, SDF), `RobotLiveController`, `RobotLiveState` (`selectedLinks`, `selectedPartIds`) | [Robot registration](../src/renderers/robot/index.ts) | `@text-to-cad/ui/renderers/robot` |\n| `ViewerCommands`, `ViewerCommandSource` (the host requests every viewer renderer takes) | [Viewer commands](../src/renderers/workspace/commands.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `createLiveRegistry`, `LiveRegistry` (the host's handle on the mounted view: its renderers' `live`) | [Live registry](../src/host/liveRegistry.ts) | `@text-to-cad/ui/host` |\n| `ModelLibrary`, `ModelLibrarySource`, `useModelThumbnail` (a host's home, under the navbar: the CAD title and byline over the host's links (GitHub, Discord, X), then the models opened before, pinned first, as cards or rows, and with none, where the host has a chooser, an \"Open File\" card that opens it; placeholder cards or rows while the list is read (again every two seconds while the home is up, so a model rebuilt meanwhile is pictured again), and a spinner over the model being opened, which takes no second press until `open` settles; `pick` only where the host has a file chooser) | [Model library](../src/library/ModelLibrary.tsx), [thumbnails](../src/library/thumbnails.ts) | `@text-to-cad/ui/library` |\n| `CadViewer` (FileViewer over a CAD client, one file by absolute path or the home: the five CAD renderers, catalog following, the home, the \"File does not exist\" and \"Could not open that file\" pages, the library's pictures) | [CAD viewer](../src/cad-viewer/CadViewer.tsx) | `@text-to-cad/ui/cad-viewer` |\n| `createCadFileSource` (a CAD client as a read-only `FileSource`: `stat` through the client's `resolveEntry`, `list` through `folder`, `search` through `search`), `createCadFileActions` (the file menu's Copy path, the absolute path, and Reveal), `normalizePath`, `baseName`, `joinPath`, `contentRevision` | [Catalog](../src/cad-viewer/catalog.ts) | `@text-to-cad/ui/catalog` |\n| `ViewerLinks`, `viewerLinks` (the version, GitHub, Discord, where a new issue opens, the newest release and how to update, and how a link is followed), `issueUrl` (a new issue filled in — its title, labels and body — its address kept under `ISSUE_URL_MAX`) | [Host types](../src/host/types.ts), [links](../src/file-viewer/navigation/links.js) | `@text-to-cad/ui/links` |\n| `buildId` (for an app's Vite config: which build this is, \"\" for the release's own, whose environment names its version in `TEXT_TO_CAD_RELEASE`, else the checkout's commit) | [Build id](../scripts/build-id.mjs) | `@text-to-cad/ui/build-id` |\n\nStart with the actual compositions: [web App](../../../apps/web/src/App.tsx) and the\nMCP app's [ModelView](../../../apps/mcp/src/ModelView.tsx), both one `CadViewer`.\nIts imports lead to the app-owned `host/`, `adapters/` and persistence\nimplementations. [Web storage](../../../apps/web/docs/storage.md) documents\nbrowser lifetimes. Shared component tests can\nuse the [explicit fake host](../src/host/testing/host.ts).\n\n## A CAD renderer's controls\n\nThe controls of a CAD renderer (STEP, GLB, mesh, robot) are its own, over the viewport: its\ntool strip, the tool in hand's sub-toolbar and its file sidebar, its view's controls (Display,\nPreview) drawn into the navbar's `navbarSlot` (`settings-ui.md#the-file-sidebar-and-the-tool-stack`); a\nDXF's are its own canvas's. Nothing a renderer does opens or closes the explorer (only the\nfile's name opens that). The layout a person gives the file sidebar — each section's size —\nand the tool stack's panels is one\nof the tab's settings (`settings.toolStack` of the tab record, `@text-to-cad/ui/tab-store`),\nbeside the appearance and the home's layout.\n\n## Preview and renderer navigation actions\n\nPreview is the shared shell's own state (`previewing`); there is no host\nprop for it and a host cannot start or observe it. It is a 3D view's alone, and each\nrenderer says whether its view is one: it declares `previewable` to the shell\n(`useRendererShell`), as STEP, GLB, STL/3MF and URDF/SRDF/SDF do. A view that does\nnot — a DXF's 2D drawing, which draws its own surface without the shell — has no\nPreview at all, no control and not a disabled one, and nothing that asks for it\n(a link or a host request, should either come to) takes it there: it keeps\nthe normal view. The CAD app's Full size, inline, is the host's display mode, not\nPreview: it shows the normal view, of any file. Preview keeps the navbar, so the app\nmenu and the explorer still lead to other files; the host is not told it is on.\nPreview and the tools view are two states: entering leaves the tools view's state as it\nis (the pose, the routine, the selection, hidden and isolated parts, Explode and Clip,\nmeasurements, the camera, the tool and its panels) and Preview starts from the model as\nauthored, with its own camera, still, and its routine at rest, drawn in Render whatever the\nfile's display mode (the tools view never draws Render); leaving throws Preview's state\naway and gives the tools view back exactly as it was, its routine included, in the file's\nmode. Preview is a view of its own, mounted over the tools view's while it is open\n(`kit/shell/PreviewView.jsx`), with its own scene, camera, pose and routine, sharing only the\npage's GPU device; the tools view's viewport stays mounted under it and nothing of it changes.\nNothing of Preview is persisted. Routines play from the file sidebar's Animation section in either\nview. Preview opens still: Orbit is off every time it opens, and its settings panel turns it\non. The file's view's `playback` — Orbit's speed, Autoplay, and the chosen speed and loop —\nis kept between visits to Preview and across a reload, and a file's routine plays on entry\nonly when its Autoplay is on. The rules are in\n[settings-ui.md](settings-ui.md#camera-animation-and-preview).\n\nA renderer can publish `FileNavigationAction[]` through\n`RendererViewProps.onNavigationActionsChange`. The CAD renderers publish one, and only\nwhile a person has put away an alert card the model survives: the card's own icon in its\ncolour, named after the alert, which brings the card back (`useAlertDismissal`,\n`kit/status/ViewerAlertCard.jsx`). The\nshared navbar shows these at its right, after the host's update button and before the\nhost's Full size and colour scheme control. Each action declares its icon, accessible label, an\noptional shorter hover `hint`, disabled state and invocation callback. Registration belongs to the mounted\nfile generation: publish an empty list on cleanup; departing renderers cannot\nreplace a new file's actions. Publish only when action metadata changes; stable\ncommands should read the current viewport through a ref, avoiding parent/child\nrender loops. These actions use existing host capabilities for effects. What a\nperson tells the agent about a CAD file is [Quick Edit](#prompt-handoff)'s.\n\nA renderer's controls for the view are the renderer's own, drawn into the box FileViewer\nhands it at the navbar's right end (`navbarSlot`): the CAD viewer's for a 3D view, Display and\nPreview, and in Preview its Preview settings and Exit preview; the navbar stays over it.\nWhere there is no navbar (a view shown small) the renderer draws them over its view. The host's `captureRequest`\ncommand is the same capture: to a composer destination it delivers the view and\nthe selection through `promptContext.deliver`. Neither route detects the platform.\n\n`navigation.openFile(path)` shows a file, by its absolute path, in this view: a pick\nin the explorer, a renderer's link. `navigation.home()`, where the host has a home,\nshows it in this view: from a file, the app menu's Back to files (the menu the navbar's\nlogo opens) leads to it; over the home the same navbar has the logo and its menu, with no\nBack to files. `CadViewer` implements both\nover the host's `onShow(path)` (`''` is the home), and gives a host a home only with a\n`library`: a host whose own navigation shows one file (Codex's file handler) passes none,\nand its view shows that file and nothing else — no home, its menu no Back to files, no\nexplorer, and no `openFile`, so a renderer's link to another file (a robot's mesh) is plain\ntext there. A host that keeps a view's own history,\nwhere no browser does (the CAD app's views), hands it as `history` (`ViewerHistory`:\n`canGoBack`, `canGoForward`, `back()`, `forward()`; `CadViewerProps.history`): the navbar\ndraws Back and Forward over it, over a file and over the home alike. What the history holds is\nthe host's (the CAD app's: every file the view showed and the home, a step like a file, the\nagent's shows and the person's moves alike); the web hands none, its page having the\nbrowser's, where the home is a step too. `onShown(path | null)` says which file the catalog\nhas on screen (null for the home, or a file still resolving or missing), for a host that\nnames its page after it or records it. The host decides what showing a file means,\nbecause only the host knows which view shows it: the web viewer is one page per machine\nand writes the file into its URL (`?file=`, a new history entry, so the browser's back\nand forward move between files, and a bare URL is the home), and the CAD app, which\nrenders in Codex (the sidebar, a tab beside each thread and a file handler) and inline in\nClaude Desktop and other MCP Apps hosts, shows it in the view that asked or, from the\nhome, launches it.\n\n## Adding a shared feature\n\n1. Implement reusable interaction and presentation in UI, with cross-consumer\n   non-React domain behavior in core. Renderer-private helpers stay with their\n   renderer in UI. Keep project, session and operating-system workflows in apps.\n2. Reuse an existing injected contract. If a new environmental effect is needed,\n   extend its narrow consumer-owned interface and implement it in each app, or\n   explicitly advertise that the host cannot perform it. Shared UI must not\n   fall back to browser globals or branch on `isWeb`/`isDesktop`.\n3. Use subscribed capability/destination state for availability and labels:\n   Quick Edit offers only what the destination and the prompt port can do. Use\n   a named additive slot for an extra app-enabled interface; the shared\n   renderer never imports the app's component or stores.\n4. Define identity, lifetime, cancellation and result semantics with the\n   contract. Publish serializable view state through the controlled binding;\n   the app chooses its storage. Keep workspace services stable across tab mounts.\n5. Update this contract or its linked domain guide, add focused shared/adapter\n   coverage and verify affected host integrations. Exercise source changes and\n   late results for asynchronous effects, and warm reuse for resource changes.\n   Run `npm run check:boundaries` and rebuild compiled packages before app checks.\n\nPlatform-agnostic UI can use DOM, canvas, React and renderer-owned workers.\nFilesystem access, transport selection, credentials, clipboard, persistent\nstorage, page navigation and native process lifecycle remain host responsibilities.\nSo do a transport's limits: a host whose channel caps one reply declares the\nmost one batched read may ask for (`createCadClient({ maxBatchBytes })`), and the\nshared loader never asks for more; the host carries a longer body in parts (the\nCAD app's tunnel reads it a range at a time), and the loader sees it whole.\n\n## Prompt handoff\n\nContext actions use `PromptContextPort.deliver(context)`. Desktop\ninserts into a compatible draft; web prepares clipboard representations. Ordinary\nexplicit copy/paste controls use the separate `ClipboardPort`. Delivery never\nsubmits a prompt. `send(context)`, present only where the host has a chat to post\nto, posts the context as the person's message now (`sent`); absent, nothing can\nsend one.\n\nThe portable types and validators live at `@text-to-cad/core/prompt`. One versioned\nbundle contains ordered text, reference and attachment parts with unique IDs.\nAn attachment has a MIME type, name and Blob or Promise of Blob. Its optional\n`about` array names reference-part IDs, so a screenshot and several selections\ncan travel together, its optional `label` (\"Annotation\") says what it is for a\nmessage that names it by path, and its optional `path` (a path, or the promise of\none) is where it is already saved: `resolveAttachmentPaths(context)` waits for\nthem all. Producers freeze resource and selection identity before\nasynchronous capture. Blob URLs are delivery leases, never portable identity.\n\nA reference contains a workspace-file identity (the file's absolute path,\n`/`-separated: `/a/b.step`, `C:/a/b.step`; validation rejects any other spelling)\nor HTTP(S) URL plus a tagged\nselection: `whole-resource`, `text-range`, or `cad-selector`. Text positions are\nzero-based UTF-16 with an exclusive end. CAD selectors use core's validated\ncadgen grammar, not STEP entity numbers. Preserve a revision when available;\nreferences do not promise to survive edits. Shared serialization handles quoting.\n\n`formatPromptMessage(context, { attachmentPath })` is the one\nmessage a Quick Edit is, whether sent, queued or copied: what the person wrote;\nthen `File: <path>`; then `References:` and one reference per line; then each\nJSON file by its label and path (`Views (cadgen snapshot --job): <job.json>`,\n`Annotation: <file.annotation.json>`), where `attachmentPath` says where it was saved.\nNo pictures are rendered, attached or sent.\n\n\n`PromptContextAction` (a host renderer's prompt action) reads the subscribed\ndestination and labels the action Add to prompt or Copy for prompt. It calls delivery during the\nuser gesture, before awaiting capture: browser activation and desktop destination\nbinding depend on this. Availability and advertised attachment/combination limits\nbelong to the host. Every result is acknowledged: added, copied, sent, partial,\ndeferred, cancelled or failed. `partIds` describes accepted/written parts, not a\nclaim that a different application pasted them.\n\nDesktop captures the compatible draft destination before awaiting attachments,\nvalidates the complete bundle and rechecks that destination before atomic draft\nacceptance. Switching chats cannot redirect an in-flight capture. Existing text\nand attachments survive; operation IDs prevent duplicate acceptance. Workspace\nmismatch uses the app's explicit Start chat here recovery. Invalid attachments\nleave the draft unchanged. Direct capture failure never shows success.\n\nWeb supports text/reference serialization and one PNG. A combined clipboard\nwrite reports that text and PNG are separate representations; some receivers\npaste only one. Unsupported attachment types or combinations fail explicitly.\nHosts must resolve/validate accepted attachments and consume failed encoders;\nthey do not transfer Promise or Blob values across native IPC.\n\nQuick Edit is the shared, host-neutral note to the agent\n([the design system](settings-ui.md#quick-edit)), and its buttons are what the host can carry out.\nEvery way it goes carries its words, references and a snapshot packet captured at\nCopy, Queue or Send. The packet includes the current view plus distinct drawn views\nwith remaining ink; an equivalent drawn entry uses the exact current framing.\nShared input and inline annotation ([annotation](annotation.md)) appear once; measurements\nare the person's and never go. Each job may override shared fields. Projection and lens are preserved.\nPackets up to 1024 bytes of compact UTF-8 JSON are inlined; larger packets are\nwritten as one temporary `.snapshot.json` and named by path. Hosts use\n`resolvePromptMessage` to resolve an attachment's optional `inlineText` before its\npath, keeping destination binding inside the gesture. Drawing, selecting and typing\nkeep data in memory. Nothing renders at the press. The agent saves inline JSON to\na temporary file before calling `cadgen snapshot --job <file>`.\n**Copy Prompt** is always there: the message goes through\n`ClipboardPort.writeText`, which takes a `Promise<string>` so the write starts\ninside the gesture while the files are still being made; its references name their\nfiles by absolute path, as copied references do (below), and every file is named by\nits path, since text carries no file (without `promptFiles`, a prompt carries none). **Queue** is there where the\ndestination is a composer (`destination.kind === \"composer\"`): `deliver`, the\ncontext for the person's next message. **Send** is there where the prompt port\nhas `send`. The context is built at the press, from what is live then; a changed\ndocument or revision never retargets it. A Quick Edit that reached its destination\n(copied, queued or sent: no `failed` or `cancelled` receipt) tells the host once,\n`usage.used('quickEdit')`, for its count; a host that throws there changes nothing.\n\n`ViewerHost.promptFiles` is a `PromptFiles`: `create({ name, input, views, annotation })`\nanswers `{ inline: compactJson }` or `{ snapshot: absolutePath }`, `read(path)` fetches JSON,\nand optional `save({ name, kind, data })` remains for older clients that export individual\nannotation files. Current chips do not call it. `createHttpPromptFiles({ origin, fetch })` from\n`@text-to-cad/core/client` posts to `POST /__cad/prompt-files`, which validates the packet in memory and returns inline JSON or writes the larger\nsnapshot and returns its path with HTTP 200. The client advertises `acceptInline: true`;\nrequests without it always receive a saved path, so already-open clients remain compatible. There is no render worker\nor polling endpoint. Guarded `GET /__cad/prompt-files/<name>` serves JSON only;\n`POST /__cad/prompt-files/save` supports those older clients. The files\nare scratch in the system's temporary directory and outlive the viewer until the\nsystem clears them. The response and persisted snapshot name input and output\nfiles by absolute path; annotation data is inline, so replay works from another\nworking folder. Both apps pass one; the MCP app's\nreaches the server through its tunnel.\n\n## App-specific interfaces\n\nThe optional CAD `live` binding receives a `CadLiveController` only while its\nviewport is mounted. `readState()` returns a detached serializable snapshot of\nthe actual resource/revision, current selections, camera, display and render\nmode. It reads the viewport camera directly, with its last camera snapshot as\nan unmount fallback. During a rebuild that retains a predecessor mesh, its\ndisplayed document revision remains in the snapshot until replacement. Apps\nown the binding registry and any IPC/tool transport;\nshared UI does not infer view state from a backend catalog or stored tab state.\n\nThe controller selects available selectors, clears selection, applies a camera,\nresets framing, applies grouped View settings, selects presets and captures a PNG.\n`thumbnail({ width, height })` is a library card's picture: it waits for the view\nto settle — the renderer's own live state saying the whole file is loaded and\ndrawn, never a timer — then draws the model framed whole from the default\ndirection at the card's aspect, on its own (no floor, grid or axes, whatever the\nperson turned on) and on transparency, so it suits either scheme, off to the side of the view, so\nthe person's camera, panels and window never show in it and nothing on screen\nchanges (`kit/viewport/thumbnail.js`; a DXF paints its fitted drawing on a canvas\nof its own). A view that goes before it settles rejects it. `useModelThumbnail`\nkeeps one per revision of a file per mounted view (a model rebuilt while it is\nopen is pictured again once its new revision is drawn), and only for the file the\nview still shows.\nOn the home, a card on screen with no picture, or one taken before its file last\nchanged (`pictured` against `modified`), is pictured out of sight wherever the host\nkeeps pictures (`onThumbnail`): `CadViewer` mounts a viewer of its own for it, over\nits own client — its own renderers and live binding, compact, behind the page at the\npicture's size — one model at a time, each once per visit, and only a model whose\nartifact status is already `compiled`, and keeps the picture through `onThumbnail`\nas it keeps the file on screen's. The home never builds anything: an unbuilt model\nkeeps its placeholder until a view shows it.\n`readState().display` and `setDisplaySettings(patch)` use the same sparse display\nschema as snapshots: `mode` selects `solid`, `shaded`, `parts`, `xray`, `hidden-line`,\nor `wireframe` (each a look at its own defaults), `grid` switches the measuring grid,\nand appearance, camera, clip and exploded are shared. Patches merge supplied fields;\na mode has no settings of its own, and a patch naming any is refused. There is no\nCustom state or legacy shape migration. Render is no mode: it is Preview's, which no\nlive command opens, so `mode: \"render\"` is refused as any unknown mode is; Preview's\nown settings are the viewer's panel's, not the display's. Camera projection/lens changes preserve viewport pose and zoom.\n`resetCamera` frames the model again without turning the camera — exactly what\nSTEP's context-menu \"Zoom to fit\" does. Cube shortcuts change only orientation.\n\nA file's view holds its camera: a mount restores it in place of the fit, and fits\nwhen there is none. Live\ncommands reject retired field names rather than maintaining a second display\nauthority. Unavailable selectors fail explicitly; topology is not silently\nloaded. Mutations return a view snapshot after the React frame; a mode switch\nwaits for the requested settings to commit, with a ten-second bound.\nLoading views\nreject commands, and an operation whose resource/revision changes or viewport\nunmounts before completion rejects its late result. On unmount, the controller\nreleases its component closure and retains only its final snapshot with\n`active:false`; every subsequent command requires showing the model tab first.\nHosts may cache that inactive snapshot without retaining a scene or moving focus.\n\n## Files, state and shutdown\n\n`FileSource` reads files; `FileActions` holds the file menu's native operations\n(Copy path, Reveal), and the viewer never writes a file. Both apps read any CAD\nfile on the machine by its absolute path, through the viewer server: what a view\nlists and searches is the server's, and a request for a file that is not there is a\n`cad-file-missing` error (`isMissingFileError`).\n\nFileViewer opens a file's document when the file is shown, and again only on `reload`: a\nrenderer follows its file itself. A CAD renderer (its document from\n`prepareWorkspaceEntry`) reads the live catalog entry, so a content change — or a write\nthat briefly empties the file — loads the next revision behind the model on screen, under\n\"Updating model…\" (a drawing's: \"Updating drawing…\"). Once a model has been shown, a\nrebuild is an update, never the loading screen.\n\nState remains controlled through FileViewer props, and everything the viewer keeps\nis the tab's (`@text-to-cad/ui/tab-store`): one record per tab, `{ version, settings,\nfiles }` (`version` is `TAB_RECORD_VERSION`, 2; a record another version wrote reads as\nthe defaults), thrown out with the tab and kept across a reload. The host supplies where it\nlives through one adapter, `TabRecordStorage` — a synchronous read and write of the\nwhole record: the web over `sessionStorage`, the CAD app over memory\n(`memoryTabRecord`) — and the package owns the record's shape, version and normalization. `settings` is\n`{ toolStack, appearance, library }`: tab-wide (the file sidebar's and tool stack's layout, the\nappearance, the home's layout), and what every renderer reads as its preferences;\n`files` holds the view of the file on screen, under `[absolute path, renderer id]`, and no\nother: a write keeps the newest alone (`TAB_FILE_LIMIT`), and `CadViewer` drops the view\nof a file it leaves — for another file or for the home — once the\ndeparting renderer's last write has landed (`files.retain(path | null)`; `null`, the home,\ndrops every view). The store reads and writes a view (`files.read`, `write`, `remove`),\nlists them all (`all()`) and merges a view's changes (`merge`). A reload of the tab shows\nthe same file, so it brings that view back; a file opened again after leaving it starts\nat the defaults. In the CAD app there is one record per view: a view the host\ncreates again (its frame re-created) starts afresh, since nothing names a view across its\nframes. A view is `{ camera, display, playback, renderer }` (`kit/shell/fileView.js`):\nthe camera is restored in place of the open-time fit, the display settings with their\nClip and Explode, the playback settings (Orbit's speed, Autoplay, and the chosen speed and\nloop), and the renderer's own slices each behind the\nsignature it was written against — a slice that no longer fits the file on screen is\ndropped, the camera, the display and the playback never. Not in it, and started afresh\non every open: the tool in hand, the selection, measurements, ink, Preview, a\nroutine's time and Quick Edit's note. Apps merge\nwhat a view changed into the store (`files.merge`); a stale view must not overwrite\nanother view's entries. Material appearance is source-owned and read-only. Live\nselection and scene ownership belong to the mounted view.\n\nA mounted view writes its view shortly after each change (the camera on every move,\ndebounced) and once more when it unmounts, and nothing after that; the store writes\nthrough synchronously, so what the tab last saw is what a reload restores. Nothing\nsaves document content or promises an asynchronous operation will finish during page\nexit. Web owns pagehide\n(which unmounts the app), focus, visibility, history and reload under a new server. Desktop\nowns window/runtime lifecycle and IPC.\n\nThe dependency checker enforces host boundaries, including worker source. The\nbrowser harness mounts real renderers with explicit fake hosts; app tests cover\nnative/clipboard delivery and multiple-view state merges. Warm-cache regression\ntests remain required for resource changes.\n\n## Keyboard scope\n\nViewer shortcuts consume only events from their own viewer, or from the page\nbackground after a pointer press in it, so another viewer or the composer never\nreceives Escape, copy or orbit keys on its behalf\n([settings-ui.md](settings-ui.md#keyboard)). Annotate's surface reads the host's\nkeyboard `platform` (`environment.platform`) for its undo and redo keys.\n\n## Host chrome slots\n\nFileViewer draws ONE navbar, the same in every app, over every file and over a host's home.\nOver the home it has only the logo and its menu (no Back to files), Back and Forward where\nthe host hands a `history`, and the host's update and Full size, each where it stands over\na file; the home's search, layout switch and Open stay\nin its Recent Files row, and its links under its title. Left: the text-to-cad \"C\" logo,\nwhich opens the app menu (`AppMenu`, `file-viewer/navigation/AppMenu.jsx`), then Back and\nForward where the host hands a `history`, then the open file's name and its ⋯ menu. The\nlogo, Back and Forward, the name and the ⋯ share one style (`NAV_ITEM_CLASS`):\ntransparent at rest, the same accent background while the pointer is on them or their menu\nis open, a pointer cursor, and no tooltip. The name opens the explorer where the host's\nfiles can be browsed (`files.list` and `files.search`), and is plain text where they\ncannot; the ⋯ offers Copy path and Reveal in Finder / Show in Explorer / Show in file\nmanager, each where `fileActions` can do it, and is absent where it can do none (no\nright-click on the name). Right, only what a host or a file adds: the host's update button\nfirst (`update`, while its install is behind), the renderer's navigation actions, the\nhost's Full size where it shows the view small\n(`fullSize`, the inline card's way to full size, over the home too), and last the box the\nrenderer draws its own controls into (over a file only). The navbar holds no settings.\n\nThe app menu is FileViewer's, built from the host's `navigation.home`, `links` and\n`environment.platform` and the person's `FileViewerProps.appSettings` (which `CadViewer`\npasses on), the same over a file and over the home. It is a dropdown on the floating surface.\nFirst **Back to files**, where the host has a home; then\nthe host's on/off settings (`appSettings`, below) as checkbox items, checked at the right,\nwhich a press turns without closing the menu; then, where the host has a tracker\n(`links.issues`), **Send feedback**, a link to a new issue titled \"Feedback: \"\n(`feedbackUrl`, `NavbarLinks.jsx`), for the person to finish, naming the version and\n`environment.platform` — it has no label: the project has none for feedback, and what is\nsaid may be a bug, a request or a question — then **GitHub** and **Discord**; and last, in\ngray, \"v<version> · Made by @<handle>\": the version links its release notes\n(`links.release`) and `MadeBy` the host's X account. The version is the release's own build's\nas it is (`v0.7.15`), and any other build's with its id (`v0.7.15-dev.b80844940`, the commit\nit was built from, `-dirty` with uncommitted changes): `viewerLinks({ version, build })`,\nwhere each app's Vite config names the build through `@text-to-cad/ui/build-id`, and a new\nissue names it the same way. The home also has GitHub, which says the project is open\nsource, Discord and X as icon links under its wordmark (`HomeLinks`), in that order.\n\nAn alert card's Report Issue opens a new issue too,\ntitled \"Issue: \" and labelled `bug`, filled in from the card (`kit/status/reportIssue.js`): its\ntitle, message and failure, the file's name, the version and platform, then its Details, cut from\ntheir end to keep the address, title and labels included, under `ISSUE_URL_MAX`. No path of the\nmachine goes with it: the file's path is its name wherever the card writes it, and a home\ndirectory in anything else is `~/`. A label is a suggestion: GitHub applies a URL's labels only\nfor someone with triage access to the repository and drops them for everyone else. A link opens\nthe ordinary way unless the host supplies\n`links.open` (a page in a sandboxed frame hands it to its host), which every link of the\nmenu follows. The colour scheme is the menu's Theme row (`colorScheme`, a `CadViewer`\nprop), over a file and on the home alike; no host has a light/dark button of its own, and\nDisplay holds no appearance control. `language` (`{ locale, inferred, choice, onChoiceChange }`, a `CadViewer` prop) is the person's\nlanguage for the app menu's Language row: `choice` null is Automatic, `inferred` what Automatic\nmeans; both apps keep the choice in the person's settings (`/__cad/language`, the `language`\nsection of `settings.json`), so it holds for every CAD view. `appSettings` (`{ id, section, label, checked, disabled?, onCheckedChange }[]`,\na `CadViewer` prop) are the host's own on/off settings: checkbox items of the app menu, in the\norder they come (Share anonymous usage data, in both apps), the same in the\nlogo's menu and the home's; nothing of them reaches a renderer, and Display holds none. The\nhost owns what each one does and where it is kept.\n`@text-to-cad/ui/consent` is the usage stats toggle both apps share:\n`useAnalyticsConsent(consent)`, which turns the host's consent call into its `appSettings` row\n(Share anonymous usage data). Nothing asks: cadgen's telemetry is on by default once a `cadgen` command\nhas said so. The host supplies the call and where the answer is kept.\n`@text-to-cad/ui/update` is the update button both apps share, from cadgen's version check:\n`UpdateButton`, the blue button a host hands the viewer as its `update` (`CadViewerProps.update`,\nfirst among the navbar's right-hand controls, an icon, over every file and over the home), which opens its card in a popover, and `useUpdateNotice(call, initial)`, which\nstarts on the notice the host already has (a CAD app's launch carries it; the web page reads it with\nits server's description), so the button draws with the page, and reads the host's call again when\nthe page regains focus. Nothing it does is an answer the server keeps: the button stays while the\ninstall is behind and goes once the update lands. The card sends its prompt\nto the agent's chat through the host's `send`, where the host can, and otherwise copies it through\n`copy`, its button then Copy prompt; the prompt has a copy icon in its top-right corner either way; the host decides which it supplies. Its\nManual installation link, the notice's `instructions`, opens through the host's `onLink`. The shell\nhandles placement and hides the toolbar in\nPreview; the host owns callbacks and preferences. None of these imply platform\ndetection or move application-specific release/network behavior into shared UI.\n\nThe explorer is a popover under the file's name (`FolderExplorer.jsx`;\n[FileViewer](file-viewer.md#the-navbar-and-the-explorer)): it floats over the view and\nnever resizes it.\n\nA file that will not open leaves the navbar over a page of the host's\n(`presentation.error`). `CadViewer`'s says \"File does not exist\" and the file's path\nwhen it is missing (`isMissingFileError`), and \"Could not open that file\" and the\nreason otherwise, each with a Go home button where the host has a home.\n\nThe viewport's edges and corners are the shell's, never the host's: the tool strip at the\ntop centre, ending in the view's own controls (Display and Preview, a 3D view's), the tool in\nhand's sub-toolbar under it, the file sidebar down the left edge, Quick Edit at the\ntop-right, and the view cube at the bottom-right. A host's one notice goes through the shell too: `notice` is drawn at the top-right once the\nfile is on screen, never while it loads or after it failed to, with Quick Edit stacked under\nit while it stands; the home never shows it. Neither app has one now.\nWhat Quick Edit offers follows the subscribed destination capability and\nthe ports, never an app name: Copy Prompt always, Queue for a composer\ndestination, Send where the prompt port has `send`. Native clipboard effects\nremain in the host implementation.\n\nA STEP's selection copy (a chip's click, the copy shortcut) and Annotate's\ncopy shortcut (the view with its annotations) use `ClipboardPort` directly, including on\ndesktop: the references as text, the view with its ink as a PNG through\n`writeImage`. Their copy shortcut follows the same path. A double-click copies\nnothing: on a face or edge it turns the view square to it, on a component or\nsubassembly it isolates it. The host supplies\n`environment.platform` for the ⌘C / Ctrl+C hint; the web host derives that field\nfrom its browser environment.\n\nAn alert card's Details have a copy icon in their box's top-right corner\n(`kit/status/ViewerAlertCard.jsx`): it copies the whole of them, however far they scroll,\nthrough `ClipboardPort.writeText`, and shows a tick for a moment. A host whose clipboard\nrefuses the write gets a line saying so under the box, where the text stays to select; a\nview with no host clipboard gets no icon. Each host makes its port copy wherever it can: the\nweb falls back to the page's copy command when the browser refuses the asynchronous\nclipboard, and so does the CAD app when its host grants its frame no clipboard.\n\nA copied reference names its file by its absolute path (`<path>#<selector>`), and so\ndoes a copied Quick Edit: every view spells a file the same way, so a reference pasted\ninto a chat or a tool says exactly which file it means.\n\nNothing of the viewer sits at the bottom centre. A host whose own control floats over\nthat edge (a chat's composer) sets `--cad-host-bottom-inset` on an ancestor to the height it\ncovers: the model library scrolls its last rows clear of it. A host whose own control\nfloats over the view's bottom-right corner sets `--cad-viewport-corner-bottom` to the room\nit takes from the bottom edge, and the view cube sits that much higher. To choose the\nopposite corner instead, set `--cad-viewport-corner-left: 2px` and\n`--cad-viewport-corner-right: auto`; their defaults are `auto` and the cube's right inset.\nCodex's tab presentation uses the left corner across sidebar, thread and file views.\nThe shared viewer consumes these layout values without identifying the host.\n\nA renderer's update status is its own: the CAD renderers show it centred under the tool\nstrip and its sub-toolbar. The navbar carries none.\n","packages/ui/src/assets/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Fassets%2FREADME.md -->","packages/ui/src/renderers/dxf/__fixtures__/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fdxf%2F__fixtures__%2FREADME.md -->","packages/ui/src/renderers/step/__fixtures__/step/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fstep%2F__fixtures__%2Fstep%2FREADME.md -->","plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=plugin.json -->","skills/bambu-labs/SKILL.md":"---\nname: bambu-labs\ndescription: Send prints to Bambu Lab printers through Bambu Connect, Bambu Lab's official app for printing from other software, or Bambu Studio. Use when the user wants to print a sliced `.gcode.3mf`, a Bambu `.gcode` or an unsliced model on a Bambu Lab printer, over Bambu Cloud or LAN. The agent opens the file in the app; the user picks the printer and starts the print there.\nlicense: MIT\n---\n\n# Bambu Labs\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nBambu Lab printers take print jobs from other software only through Bambu\nConnect, Bambu Lab's desktop app for third-party tools\n(https://wiki.bambulab.com/en/software/third-party-integration). This skill\nopens a file in Bambu Connect, or an unsliced model in Bambu Studio; the user\npicks the printer and starts the print there. The agent never controls a\nprinter and never asks for access codes.\n\n## Requirements\n\nBambu Connect (Windows 10 or later, macOS 13 or later), signed in to the user's\nBambu Lab account, with the printer on that account or reachable in LAN mode\n(https://wiki.bambulab.com/en/software/bambu-connect). On Linux, where Bambu\nConnect is still in development, use Bambu Studio.\n\n## Choose the handoff\n\n| What the user has | Handoff |\n| --- | --- |\n| A sliced `.gcode.3mf`, from `$gcode` or Bambu Studio's or OrcaSlicer's \"Export plate sliced file\" | Open it in Bambu Connect |\n| A plain `.gcode` sliced with this printer's Bambu profile | Open it in Bambu Connect; if Bambu Connect refuses it, slice the model in Bambu Studio instead |\n| A model with no slice: `.3mf`, `.stl` or `.step` | Open it in Bambu Studio, which slices it and sends it to the printer itself |\n\n## Open a file in Bambu Connect\n\nBambu Connect imports a file from a `bambu-connect://import-file` link with\nthree parameters: `path`, the file's absolute path, and `name`, the name to\nshow, each percent-encoded the way JavaScript's `encodeURIComponent` does it\n(Python: `urllib.parse.quote(value, safe=\"\")`); and `version=1.0.0`. For\n`/Users/me/prints/bracket.gcode.3mf`:\n\n```text\nbambu-connect://import-file?path=%2FUsers%2Fme%2Fprints%2Fbracket.gcode.3mf&name=bracket&version=1.0.0\n```\n\nOpen the link with `open \"<link>\"` on macOS, or `Start-Process \"<link>\"` in\nPowerShell on Windows. Bambu Connect comes forward with the file loaded.\n\n## Open a model in Bambu Studio\n\nOn macOS run `open -a BambuStudio <file>` (some installs name the app\n`Bambu Studio`). On Windows open the file from Bambu Studio's File menu, or run\n`Start-Process <file>` when Bambu Studio is the file's default app. On Linux run\n`bambu-studio <file>`, or open it from the AppImage or Flatpak.\n\n## Finish in the app\n\nBefore the user presses Print, tell them what to check: the printer, plate\ntype, nozzle, filament and AMS mapping, a clear build plate, and someone nearby\nfor the first layer. Never report a print as started: the agent can't see the\nprinter, and the app shows the job and its progress.\n\n## Out of scope\n\nStarting, pausing or cancelling prints without the user, and reading printer\nstatus: Bambu Lab's firmware takes those from other software only through Bambu\nConnect, or in Developer Mode, which is LAN-only and disconnects the printer\nfrom Bambu Cloud. For slicing, use `$gcode` or Bambu Studio.\n","skills/bambu-labs/agents/openai.yaml":"interface:\n  display_name: \"Bambu Labs\"\n  short_description: \"Send prints to Bambu printers via Bambu Connect\"\n  default_prompt: \"Use $bambu-labs to send my sliced print to my Bambu Lab printer through Bambu Connect.\"\n","skills/cad/SKILL.md":"---\nname: cad\ndescription: Create/edit parametric CAD models, organize CAD projects, export STEP/STL/3MF/GLB files, resolve prompt references, and measure geometry with cadgen. Open and visually review existing STEP/STP, STL, 3MF and GLB files in CAD Viewer.\nlicense: MIT\n---\n\n# CAD modeling and inspection\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files for the current interface.\n\n## Start with the task\n\nRead only the references needed for the request.\n\n| Task | First action | Reference |\n| --- | --- | --- |\n| **Create or edit a part or assembly** | Find the existing Python model, or create a decorated model below; edit source and run `python <model>.py`. | [Model contract](references/step-generation.md), [shape construction](references/build123d-modeling.md); [positioning](references/positioning.md) for assemblies |\n| **Organize a CAD project** | Follow its existing layout; for a new multi-model project use `src/`, format output folders, and a model catalog. | [Project layout](references/project-layout.md), [minimal starters](references/project-template.md) |\n| **Export STL, 3MF or GLB** | Add a mesh decorator for a maintained output, or run the format's `build INPUT.step OUT` command for a one-off export. | [Mesh exports](references/supported-exports.md) |\n| **Resolve a reference from a prompt** | Identify its saved STEP/STP document, open it with `read_scene`, and call `scene.resolve(ref)` as shown below. | [Reference syntax and inspection](references/inspection-and-validation.md#reference-syntax) |\n| **Measure or check geometry** | Write a Python check using native build123d geometry and, where useful, `cadgen.geometry`. | [Inspection and validation](references/inspection-and-validation.md) |\n| **Model from an image or drawing** | Extract the specified dimensions and record meaningful assumptions. | [Interpreting the request](references/cad-brief.md) |\n| **Open an existing STEP/STP, STL, 3MF or GLB** | Show it to the user. | [Show the model](#show-the-model) |\n| **Review appearance or motion** | Snapshot the saved document; use declared kinematics or animation for poses and clips. | [Snapshots](references/snapshot-review.md), [kinematics](references/kinematics.md) |\n| **Diagnose a failure** | Read the error and check the relevant model, geometry or command contract. | [Repair loop](references/repair-loop.md), [version migration](references/migrations.md) |\n| **A message says to migrate** | Do the migration now; an unmigrated model silently loses kinematics, materials and animation. | [Version migration](references/migrations.md) |\n\nFor 2D DXF drawings use `$dxf`; this skill owns any 3D part the drawing projects.\nUse the corresponding robot-description skill for URDF, SRDF or SDF.\n\n## Setup and paths\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors. Use the relevant\nsubcommand's `--help` for additional flags.\n\nRun project commands from the CAD project root. CLI input/output paths and\n`read_scene`/`read_step` paths are working-directory-relative; decorator `out=`\npaths are **relative to the model script**. Anchor file inputs on `__file__`\nwhen the model must run from any directory.\n\n## Create or edit a model\n\nA model is a plain Python script with a parameterless decorated function\nreturning a build123d shape. Use one model per entrypoint, with the script and\nits declared outputs sharing a filename stem. For example, `src/bracket.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 40.0\n\n\n@step(out=\"../STEP/bracket.step\")\ndef bracket():\n    body = bd.Box(WIDTH, 20, 6)\n    body.label = \"bracket\"\n    return body\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython src/bracket.py\n```\n\n- Edit the model source when it exists, then run it to regenerate its outputs.\n  Document export and snapshot commands take saved files and never run source.\n- Keep meaningful dimensions explicit. Use millimeters and XY/+Z unless the\n  task or project specifies another convention; choose a useful functional datum.\n  Prefer closed, positive-volume solids for physical parts, while honoring\n  requests for surfaces or construction geometry.\n- Put parameterized geometry in ordinary factory functions; a decorated model\n  selects a configuration. Keep module bodies cheap: create geometry and read\n  CAD inputs inside the model or its helpers. Use the lazy `bd` import above;\n  use postponed annotations when annotations mention `bd` types.\n- Call child models inside the assembly model. Place their results with\n  `.moved()` or `Location * shape` to preserve shared geometry. Use meaningful\n  occurrence labels and source-defined placements. Rerun the parent assembly\n  to incorporate a changed child.\n- Read vendor STEP inputs with `cadgen.read_step`. Every file a build opens is\n  an input on its own, whatever reads it (`json.load`, `np.load`,\n  `bd.import_step`, a project font): nothing is declared. Never read a model's own\n  output as its input. Geometry must not depend on untracked\n  time, random values, environment variables or the working directory.\n- When named purchasable parts are needed, search `$step-parts` before making\n  placeholders. Record an unsuccessful search and any placeholder assumptions.\n\nFor unfamiliar dimensions or interfaces, record the assumptions needed to model\nand verify them. Ask for missing information when it materially affects the\nrequested result. Inspection and export requests do not need a modeling brief.\n\n## Mesh exports\n\nStack `@stl`, `@threemf` or `@glb` on the model for outputs that should be\nmaintained on every run. A model may declare only meshes; STEP is optional.\nFor a one-off export from an existing generated or imported STEP:\n\n```bash\ncadgen stl build STEP/bracket.step STL/bracket.stl\ncadgen 3mf build STEP/bracket.step 3MF/bracket.3mf\ncadgen glb build STEP/bracket.step GLB/bracket.glb\n```\n\nOmitting OUT writes one sibling file with the requested extension. It does\nnot discover declared model variants. See [mesh exports](references/supported-exports.md)\nfor decorator examples, mesh tolerances and animated GLB.\n\n## Prompt references and inspection\n\nA reference such as `/work/robot/STEP/assembly.step#o1.2.f7` identifies geometry\nin a particular saved document. Its file part is the document's absolute path as\nthe CAD viewer copied it, with the file's real name and extension (in quotes when it\nholds a space or `#`). A selection of several entities is copied as one line, its\nrefs comma-separated (`/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8`). Open that\npath with `read_scene`, and pass the whole line to `resolve()`, which returns a tuple\nof one selection per ref, in order:\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"/work/robot/STEP/assembly.step\")\nfor selection in scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8\"):\n    face = selection.shape()  # owned native geometry, in document world coordinates\n    print(selection.ref, face.area)\n\n(selection,) = scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7\")  # one ref\n```\n\nA Quick Edit prompt names its document under `File:`, selected entities under\n`References:`, and snapshot context as inline JSON or a `.snapshot.json` path.\nTo render, save inline JSON to a temporary `.snapshot.json` and run\n`cadgen snapshot --job <snapshot file>` (`--job` takes a file path).\nInspect supplied annotations before editing. Without annotations, the captured\nview is optional context; render it when useful to the request. After editing,\nreplay relevant views to check the change. See\n[annotations and Quick Edit views](references/snapshot-review.md#annotations-and-quick-edit-views)\nfor the packet schema and capture behavior.\n\nA reference into a file that is not a STEP names what the viewer picked in it, in that\nfile's own terms (none is a STEP selector `read_scene` resolves):\n\n| File | Reference | Means |\n| --- | --- | --- |\n| URDF, SRDF, SDF | `/work/arm/arm.urdf#upper_arm` | the link `upper_arm` |\n| GLB | `/work/out/model.glb#n12` | the glTF node `nodes[12]` (0-based, the file's own index) |\n| 3MF | `/work/out/plate.3mf#o2.1` | the 2nd `<build><item>`'s 1st `<component>` (1-based); `#o3` is the 3rd build item |\n| STL | the file alone, on `File:` | one mesh: nothing in it is picked |\n\n\nFor a bare `#o1.2.f7`, use the identified target file. Do not guess between\nambiguous files or labels. Numeric refs belong to that saved revision;\nreopen and reselect after rebuilding. The [inspection reference](references/inspection-and-validation.md)\ncovers label aliases, enumeration, measurements and small reusable operations.\n\nThere is no inspect CLI. Put exploratory checks in the project's ignored\n`tmp/` (or system `/tmp/`); retain reusable checks in `checks/` or its existing\ntest directory. Keep them outside model-source and raw-output folders.\n\n## Verify and hand off\n\nChoose checks from the requested dimensions, clearances and topology. For STEP\noutputs, check the saved artifact with `read_scene` or `read_step`. For mesh-only\nmodels, check the model's returned native geometry and review the mesh output;\ndo not add a STEP solely to satisfy the workflow. Report units, thresholds,\nselected geometry and untested requirements. A failed computation is not a pass.\n\nAfter creating or visibly changing geometry, generate and review at least one\nsnapshot of the resulting STEP or mesh. Snapshots are your own review: always\nrender and read them yourself, never rely on the viewer for it. Choose additional\nviews to expose the features under review; see\n[snapshot policy and options](references/snapshot-review.md).\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\ncadgen stl snapshot STL/bracket.stl tmp/mesh.png\n```\n\nRepair failures in the source and rerun the affected checks. Use geometry and\nimages for CAD comparisons; path-targeted git status is bookkeeping, not\ngeometric evidence. `cadgen store why <model>.py` explains unexpected rebuilds;\n`python <model>.py --force` forces one model, and `cadgen daemon status` shows\nbuild progress. More diagnostics are in the [model contract](references/step-generation.md).\n\nInclude output files, checks actually run, and material assumptions or\nlimitations in the final response. The user sees the model in the viewer (Show\nthe model), so don't attach snapshots unless they ask for an image. Explain any\nsnapshot skip or failure using the cases in the snapshot reference.\n\n### Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nGenerate changed artifacts first: the viewer never runs model scripts. Existing\nSTEP files compile on open when needed. Exact topology selection and measurement\nrequire STEP; supported mesh viewers also offer approximate measurements on triangles.\n","skills/cad/agents/openai.yaml":"interface:\n  display_name: \"CAD\"\n  short_description: \"Generate and validate CAD artifacts.\"\n  default_prompt: \"Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","skills/cad/references/animation-deformation.md":"# Tube deformation and morph export\n\nRead this reference for flexible swept bodies in an embedded animation, or for\nexporting that deformation into GLB. The [motion reference](kinematics.md)\ncovers ordinary joints, poses, clip declarations and video rendering.\n\n## Deforming an existing tube\n\nCall `.deformTube()` on an animation handle for a continuous swept STEP body:\n\n```javascript\n// Inside update(t, m), with restPath and posedPath defined for this frame:\nm.get(\"tendon\").deformTube({\n  rest: restPath,\n  path: posedPath,\n  twistDeg: 0,\n  maxSegmentLength: 1,\n});\n```\n\nBoth paths are `{normal, segments}` in assembly coordinates. `normal` is a\nrequired transverse frame seed on each path. Segments must connect tangentially:\n\n| Segment | Fields |\n| --- | --- |\n| Line | `{kind: \"line\", start, end}` |\n| Arc | `{kind: \"arc\", center, axis, start, sweepDeg}` |\n| Cubic Bezier | `{kind: \"bezier\", points: [p0, p1, p2, p3]}` |\n\nPositions are vec3 millimetres; angles are degrees. Normalized arc length maps\nthe rest path to the posed path. Check tendon length, bend radius and collisions\nseparately: deformation does not solve those constraints. `twistDeg` rotates\nauthored cross-sections; it does not calculate spool payout.\n\n`maxSegmentLength` controls one-time longitudinal mesh refinement in millimetres\n(default `1`, minimum `0.05`). The rest mesh, normals and topology remain\ncontinuous, and shared source buffers remain immutable. Omitting deformation in\na later frame restores the rest shape.\n\nOptional `braid: {pitch: 0.8, depth: 0.02, strands: 8}` adds procedural fiber\ncolor and normal relief over the swept core. Pitch and depth are millimetres;\nthe strand count must be even. This is a surface finish, not additional CAD or\ncollision geometry.\n\n## Exporting deformation to GLB\n\nAnimated GLB refuses tube deformation by default. Request morph targets explicitly:\n\n```bash\ncadgen glb build STEP/hand.step GLB/animated/hand.glb \\\n  --animation '{\"clip\": \"fist\", \"fps\": 24, \"seconds\": 6,\n                \"deform\": \"morph\", \"deformTolerance\": 1.0}'\n```\n\nEach deforming node gets per-vertex position and normal deltas against one base\nmesh, plus a weights channel. Interpolation between baked poses can differ from\nthe procedural deformation. The exporter fits targets per occurrence against\n`deformTolerance` (millimetres, default `1.0`, range `0.01`..`10`), checking a\ngrid four times finer than `fps`, at least 96 Hz. This sampled check is not a\ncontinuous-time error guarantee. The export summary reports `targets`, `bytes`,\n`runtimeBytes`, `deviationMm` and `fitGridHz`.\n\nThe exporter refuses a bake exceeding 512 MiB of estimated morph playback\nmemory: 32 bytes per vertex per target, or 16 without normals. To reduce it,\nrelax `deformTolerance`, shorten `seconds`, increase `--mesh-tolerance`, or\nincrease the clip's `maxSegmentLength`, as the required fidelity permits.\n\nLimitations:\n\n- Procedural braid shading is not exported. The GLB retains the tube's geometry\n  and motion with a smooth surface.\n- Changing a tube's `rest` path during the clip is refused because morph targets\n  share one base mesh. Use `.rotate()` or `.translate()` for rigid motion.\n- A tube held in a constant deformed pose exports in that pose without morph\n  targets. `\"deform\": \"rest\"` instead explicitly freezes tubes at rest and warns;\n  use it only when that is the intended output.\n\nThe CAD Viewer plays baked morph animation from GLB, but the original clip's\nprocedural controls remain in the STEP sidecar. Render a video when the output\nneeds effects GLB cannot carry; see [motion review](kinematics.md#reviewing-motion).\n","skills/cad/references/build123d-modeling.md":"# build123d modeling patterns\n\nRead this file when constructing or repairing native CAD geometry. Model scripts\nreturn build123d shapes; decorators declare the output files. See the\n[model contract](step-generation.md) for execution and composition.\n\n## Construction choices\n\nChoose a construction that expresses the controlling dimensions directly and\npreserves the intended geometry. Builder contexts and algebraic modeling are\nboth valid; profile operations, solid features and surface construction suit\ndifferent shapes. The examples and remedies below are options, not a required\nfeature sequence.\n\nUse meaningful parameters and functional datums. Keep separately manufactured\nor moving parts identifiable in an assembly; fuse bodies where the design calls\nfor one continuous part. Prefer solids for physical parts, but do not close or\nthicken surfaces when the user requested surface geometry.\n\nOperation order depends on the design. Applying finishing features late often\nmakes selectors simpler; constructing them in a profile can be more robust for\nsome outlines. Through-cut tools should span the intended material; choose any\nextra extent relative to the geometry and nearby features, not a fixed distance.\n\n## Selection and topology\n\nAn **occurrence** is a placed assembly node. Its geometry contains bodies,\nfaces, edges and vertices. For inspection, keep the canonical `ref` and\n`occurrence_ref`; see [inspection](inspection-and-validation.md) for enumeration.\nNumeric selectors belong to one saved revision.\n\nFor construction, select by the feature's geometry or datum where practical:\nnormal, axis, plane, position or curve type. Re-evaluate selections after an\noperation that changes the relevant topology. Select a fillet's or chamfer's\nedges from the solid it rounds, after that solid's last operation: an\noperation returns new native topology wherever it changed the solid, so an\nedge held from before it may not be the solid's own. A fillet silently skips\nsuch an edge, and fails when none is left; a chamfer fails on it. Shape\nequality is build123d's own and compares native handles, so\n`edge in solid.edges()` is true only for the solid's own edge.\n\n## Labels and assemblies\n\nUse concise, meaningful native labels on exported parts and assembly occurrences,\nincluding repeated placements such as `m3_screw:front_left`:\n\n```python\n# Inside an assembly model, after constructing and placing its parts:\nbase.label = \"base\"\nlid.label = \"lid\"\nassembly = bd.Compound(children=[base, lid], label=\"electronics_enclosure\")\n```\n\nLabels describe role or placement; they need not repeat topology categories.\nA feature fused into or cut from a body does not retain a separate occurrence\nlabel. Keep its intent in parameters, construction datums and geometric checks.\nDo not promise persistent face/edge names through STEP export.\n\nSee [positioning](positioning.md) for assembly datums, joints, explicit transforms\nand checks of saved mating relationships. Source joints position geometry;\n[kinematics](kinematics.md) separately declares motion for the viewer.\n\n## Colour and finish\n\nNative `Color` channels are linear RGB. Use `cadgen.srgb` for a colour specified\nas a display hex value:\n\n```python\nfrom cadgen import srgb\n\nbody.color = srgb(\"#2E3742\")\nglass.color = srgb(\"#38414D\", 0.42)\n```\n\nSet native colour on leaf occurrences: a colour on a group compound does not\npropagate to its leaves in the render tree. Named material assignments can target\na group and expand to its leaves:\n\n```python\n@step(materials={\n    \"definitions\": {\n        \"cast\": {\"name\": \"As-cast steel\", \"roughness\": 0.85, \"metalness\": 0.2},\n        \"ground\": {\"name\": \"Ground steel\", \"roughness\": 0.25, \"metalness\": 0.9},\n    },\n    \"assignments\": [\n        {\"targets\": [\"#housing\"], \"material\": \"cast\"},\n        {\"targets\": [\"#journal\"], \"material\": \"ground\"},\n    ],\n})\ndef gearbox():\n    ...\n```\n\nMaterial keys are optional `name`, `baseColor`, `roughness`, `metalness`,\n`clearcoat`, `clearcoatRoughness`, and `opacity`. Numeric channels are finite\n0..1 values; `baseColor` is `#RRGGBB`. STEP's sidecar carries these finishes;\n`baseColor` does not change STEP colours or geometry bytes. Finishes inherit\nthrough cached child composition and are consumed by Render and GLB export.\nNormal CAD display keeps colour/opacity with workbench shading. Dynamically\nsetting `cad_material` is unsupported.\n\n## Placement and frame pitfalls\n\n- **Rotation frame:** do not infer a local rotation axis from Euler-angle\n  spelling. Check the transformed basis for a non-global `Plane`; explicit\n  direction vectors or an axis-angle construction can make the intended frame\n  clearer. A valid loft can still join incorrectly oriented sections.\n- **Primitive alignment:** `align=None` preserves the primitive's raw datum;\n  it does not mean centered. For example, a cylinder is based at Z=0 while a\n  box starts at a corner. Set alignment deliberately when placement depends on it.\n- **Absolute versus relative placement:** `.located(loc)` replaces location;\n  `.moved(loc)` and `Location * shape` compose it. Applying `.located()` after\n  a rotation can therefore discard the orientation. For placed child models,\n  use `.moved()` or multiplication to preserve shared geometry; `.located()`\n  copies it and loses the child link. Absolute placement remains a native API\n  option when replacement and copying are actually intended.\n\n```python\n# A local box, rotated before placement:\nbox = bd.Solid.make_box(1, 1, 1)\nrotated = box.rotate(bd.Axis.Z, 90)\nplaced = bd.Location((5, 0, 0)) * rotated  # retains the rotation\n```\n\nIn build123d 0.11.1, a moved assembly compound's `intersect()` can traverse\nunmoved `.children` despite `.wrapped` carrying the correct placement. Extract\nplaced `.solids()` or operate on the placed OCCT shapes when affected.\n`cadgen.geometry.overlap_volume` takes individual solids and queries private\ncopies of their placed geometry. Check the actual pose when diagnosing an\nunexpectedly constant interference result.\n\n## Loft and sampled-profile pitfalls\n\nFor sampled sections, keep feature order, edge correspondence and orientation\nconsistent across stations. Twisting or rippling can be present in valid\ngeometry, so check the intended sections and review the surface.\n\n- Corresponding feature rails or consistent samples per band can prevent a\n  feature from drifting between sections. Equal sample counts alone do not\n  guarantee a correct loft.\n- Independently easing every interval to zero slope can introduce unintended\n  flat spots. Choose interpolation for the desired continuity. Smooth measured\n  noise only within the task's geometric tolerance.\n- A disconnected or self-crossing section can produce misleading downstream\n  loft failures. Inspect section wires as well as face validity. Increasing\n  prefixes or adjacent section pairs can localize a failing region.\n- A ruled loft can help diagnose a smooth-loft failure, but changes the surface;\n  use it as a replacement only if its faceting and continuity meet the design.\n- For fields built by blending sampled component profiles, a component ending\n  inside its neighbor may leave a steep wall. Inspect the blend function and\n  continuity before increasing sample density. Adjust component overlap or the\n  blend only if it preserves the required boundary.\n\n## Boolean and finishing pitfalls\n\nKernel behavior depends on topology, tolerances and the installed build123d/OCP\nversion. Diagnose the failing operation before changing the construction.\n\n- **Many tools:** batch independent cuts when that avoids repeatedly rebuilding\n  the same intersection network. Overlapping tools may need staged operations;\n  sequential cuts are legitimate when later features depend on earlier ones.\n  Restrict excessive tool extents when they create unnecessary intersections.\n- **Large spline surfaces:** even batched cuts can be expensive. Localize the\n  expensive operation with timing or a stack sample, then consider simpler\n  surfaces, smaller tool regions or a different construction of the same feature.\n  A groove's screen size is not a reason to remove it from the CAD model.\n- **Near tangency or coincident boundaries:** inspect the removed/added region\n  and solid count, not only a successful return. A direct profile construction\n  may avoid an unstable Boolean intersection, when it describes the same shape.\n- **Fillets and chamfers:** check selected edges and available local space.\n  Smaller radii, different feature ordering, grouped selections or a profile\n  bevel are possible remedies, subject to the required dimensions. Do not\n  silently reduce or omit a specified feature through a retry ladder.\n- **Tangent chains or complex outlines:** some kernel versions fail or crash\n  during finishing operations. Isolate a reproducible case; a profile bevel or\n  separately constructed transition is a workaround, not a ban on 3D fillets.\n- **2D algebra:** inspect intermediate types. A `ShapeList` participates in\n  Python list operations, which may concatenate instead of fusing geometry.\n  Check wire winding/face normals before extrusion, especially after mirroring\n  sampled points. Confirm an intersection is nonempty before using it as a cut.\n- **Dense periodic splines:** build123d 0.10/OCP 7.9 have shown failures in taper,\n  inward offset and coincident-face fusion for some densely sampled profiles.\n  If reproduced, simplify the representation or construct the offset/transition\n  explicitly and verify its distance, continuity and topology. Numerical offsets\n  are an option, not a universal replacement for kernel offsets.\n\nPreserve the requested geometry while repairing it. If an exact feature cannot\nbe built, report the limitation or seek a design decision rather than quietly\nsubstituting an approximation.\n\n## Validity and visual artifacts\n\nTopology validity does not prove positive orientation, the requested shape, or\nsuitability for a later Boolean. Check signed volume per intended solid;\naggregate volumes can hide inverted members. Open shells are legitimate when\nsurfaces were requested.\n\nUse `cadgen.geometry.topology_errors`, `boundary_edges` and, when relevant,\n`self_intersections` on saved geometry. During a failing construction, check\nintermediates around the suspect operation. A validity gate inside a model\nbody — a retry ladder that accepts a fillet only when the result is sound, a\nstage check on a casting — uses build123d's `shape.is_valid` and\n`cadgen.geometry.is_sound` (the `BRepAlgoAPI_Check` verdict, which also\nidentifies Boolean-suitability issues such as tiny edges). A check costs\nkernel time, so gate where a failure is plausible rather than after every\nsimple operation. Any repair must preserve the dimensions being checked. See\n[inspection](inspection-and-validation.md).\n\nA periodic cylinder or revolved face has a seam edge that may appear in CAD\nlinework. Use shaded display or another camera to distinguish a display seam\nfrom a crack. Do not rotate a finished, datum-constrained part solely to hide\nits seam.\n\nFor further failure diagnosis and before/after checks, see [repair loop](repair-loop.md).\n","skills/cad/references/cad-brief.md":"# Interpreting a CAD request\n\nUse this reference when modeling from prose, reference images or technical\ndrawings. Record only the dimensions, interfaces, outputs and assumptions needed\nto construct and check the requested geometry. A simple part may need a sentence;\na complex assembly may benefit from a dimension/interface checklist. There is\nno required brief format, and direct inspection or export needs no modeling brief.\n\nClarify missing or conflicting information when it materially affects the result,\nespecially a mating interface or required scale. Otherwise proceed with explicit\nassumptions. Do not ask the user to fill out a template.\n\n## Reference images\n\n- Distinguish reproducing a part from using an image as design inspiration.\n- Establish scale from a supplied dimension or known reference when available.\n  Unscaled images can guide proportions; they do not establish fit dimensions.\n- Record inferred dimensions as assumptions. Ask for scale when fit depends on it.\n- For reproduction, compare a snapshot from a similar viewpoint with the image.\n\n## Technical drawings\n\n- Read units, projection convention, revision and notes; identify each view and\n  section before mapping dimensions to model axes.\n- Prefer explicit dimensions over image proportions. Resolve conflicting\n  dimensioned sources rather than silently choosing one.\n- Preserve multiplicity (`4X`), `TYP.`, threads, counterbores, countersinks and\n  internal depths shown in sections. Track the requirements for later checks.\n- Derive unspecified geometry from constraints where possible. Treat dimensions\n  estimated from a drawing image as assumptions, never as verified callouts.\n- Verify the specified dimensions against saved geometry using Python\n  measurements, or explicitly report those not verified. See\n  [inspection and validation](inspection-and-validation.md).\n\nBefore modeling, identify the relevant source/output paths, units, coordinate\nconvention and functional interfaces. Choose construction and validation methods\nthat make the controlling dimensions easy to express and check.\n","skills/cad/references/inspection-and-validation.md":"# Inspecting and checking saved CAD in Python\n\nUse small Python scripts against the saved STEP/STP. `cadgen step inspect`\nand `cadgen.step.inspect` have been removed. Choose the relevant entities,\nmeasurements and acceptance criteria in the script; there is no replacement\ninspection command or universal validation report.\n\nPut exploratory scripts in the CAD project's ignored `tmp/`, for example\n`tmp/check_clearance.py`; system `/tmp/` is also fine for disposable work.\nKeep reusable design checks in `checks/` (for example `checks/clearance.py`),\nor use the project's existing test location. Commit those checks so they can\nbe rerun after a model changes. Create the directory only when needed.\nKeep checks outside `src/` model directories and raw format folders such as\n`STEP/`. Examples below use paths relative to the CAD project root; run from\nthere, or anchor input paths explicitly when a script must run elsewhere.\n\nReport what was measured, the units, the threshold and the selected geometry.\nA failed computation is not a passing check, and an untested relationship has\nno verdict. Run the model first when you want to check newly authored geometry.\n\n## Opening and selecting geometry\n\n```python\nfrom cadgen import read_step, read_scene\n\nshape = read_step(\"STEP/bracket.step\")  # native build123d geometry\nscene = read_scene(\"STEP/assembly.step\")  # hierarchy and exact selector refs\n\nprint(scene.document_hash)\nfor occurrence in scene.leaves():\n    print(occurrence.ref, occurrence.label, occurrence.prototype_id)\n\n(housing,) = scene.resolve(\"#housing\")\nprint(housing.ref, housing.kind)\nsolid_or_compound = housing.shape()\nfor face in housing.entities(\"face\"):\n    print(face.ref, face.shape().area)\n```\n\n`read_step` keeps its existing name. `read_scene` replaces the old\n`load_step_scene` helper. Inside a model, the document either reads is a build\ninput, as every file a build reads is. Paths expand `~` and resolve relative to the working directory.\n\n- `scene.roots` and `occurrence.children` are tuples. `scene.leaves()` yields\n  geometry occurrences, including repeated copies. A leaf can hold multiple\n  solids; the reader does not infer manufactured parts.\n- `scene.resolve(ref)` returns a tuple of selections, one per ref in the line\n  (see [Reference syntax](#reference-syntax)). A selection has `ref`, `kind`,\n  `occurrence_ref`, `shape()` and `entities(kind)`. Kinds for enumeration are\n  `\"shape\"`, `\"face\"`, `\"edge\"`, `\"vertex\"`. Shape entities (`sN`) are solids,\n  or shells when no solids exist, or the leaf geometry itself.\n- Every `shape()` is caller-owned exact build123d geometry in the document's\n  world coordinates, including ancestor placements. A group produces an\n  unfused compound. Modifying the result cannot change the scene or another\n  selection. Keep the returned shape when making several queries about it.\n- `prototype_id` identifies shared geometry within this scene, allowing a\n  script to avoid repeating a suitable body-local check. It is not a part\n  number or an identity across revisions. Numeric self-intersection tests can\n  behave differently at different placements; state which placements ran.\n- A scene is bound to one document hash. Replacing the file does not change an\n  already opened scene. Reopen it to inspect the new revision. Selector IDs\n  are revision-scoped, not persistent feature names.\n- Opening reuses the canonical BREP cache, decoding prototypes on demand.\n  No display surfaces, tessellation, source discovery or source execution are\n  needed. Cached inventory and occurrence lookup defer the build123d import\n  until native geometry is requested. Keep the scene open: repeated reads\n  still hash the document bytes, and fresh Python processes pay import costs.\n- The scene describes the saved placement. It does not evaluate sidecar\n  kinematics or animation poses.\n\n### Reference syntax\n\n```text\n#o1.2          occurrence or subassembly\n#o1.2.f7       canonical face 7 on that leaf occurrence\n#housing.e3   canonical edge 3 on the occurrence labelled housing\n#f7           face 7, only when the scene has exactly one leaf\npart.step#o1  a file-prefixed ref matching the opened STEP\npart.step#o1.2.f7,o1.2.f8  several refs, as the viewer copies a multi-selection\n#o1.2.f7,f8   a bare entity after an occurrence or label belongs to it\n```\n\n`resolve()` takes a whole copied line, one ref or a comma list, and returns a\ntuple with one selection per ref, in order; any ref that does not resolve\nraises. Unpack a single ref: `(face,) = scene.resolve(\"#o1.2.f7\")`.\nTo enumerate a subassembly's faces, resolve it, `(group,) = scene.resolve(\"#group\")`,\nand call `group.entities(\"face\")`; a group has no independent\nface ordinals. Enumeration preserves canonical occurrence IDs and never\nmerges coincident instances. Face and edge IDs agree with the viewer; vertex\nIDs enumerate native STEP vertices (the viewer currently exposes no vertex\ntable).\n\nLabels use letters, digits, `_` and `:`, and cannot start with a digit or\ncollide with numeric reference syntax. Other labels remain accessible through\nnumeric IDs. Duplicate labels receive numbered aliases in occurrence order;\nresolving the ambiguous bare label raises and lists candidates. No fuzzy\nmatching or choosing the first match.\n\nA copied reference's file prefix is the file's absolute path, with the file's real\nname and extension (in quotes when it holds a space or `#`). `resolve()` reads the\nprefix as a path, with `~` expanded, links followed and a relative one read from the\nworking directory, and it must name the opened document.\nA relative prefix that names no file from where you run still matches the end of\nthe document's path. A prefix naming a different document is rejected. Do not\nguess between ambiguous files.\n\n## Measurements\n\nMost measurements already have native build123d interfaces:\n\n```python\n# p, q: chosen points; u, v, direction, normal: explicit vectors\nlength = (q - p).length\nsigned_offset = (q - p).dot(direction.normalized())\nangle_degrees = u.get_angle(v)\nsigned_angle_degrees = u.get_signed_angle(v, normal)\n\n# Select the actual geometric feature that defines the dimension.\ncenter = circular_edge.arc_center\nradius = circular_edge.radius\ndiameter = 2 * radius\nlength = edge.length\narea = face.area\nvolume = solid.volume\nsize = shape.bounding_box().size\n```\n\nA bounding box dimension is an envelope, not a hole diameter or wall\nthickness. A surface centroid is not necessarily a datum. Use analytic\ncenters, axes and selected points when those define the requested dimension.\nThe default `bounding_box()` searches for tight bounds and can be expensive\non curved geometry. Use `bounding_box(optimal=False)` for a fast conservative\nenvelope when filtering candidate collision pairs, not for a tight dimension.\n\n```python\nfrom cadgen.geometry import closest_points\n\nresult = closest_points(a, b)\nprint(result.distance, result.point_a, result.point_b)\n```\n\n`closest_points(Shape, Shape) -> ClosestPoints` returns a nonnegative minimum\nset distance and one witness pair in the input frame. Solid overlap, contact\nand containment have distance zero; it does not measure penetration depth.\nWitnesses may be nonunique or internal to a solid. Pass selected faces/shells\nwhen asking about boundary separation. Minimum distance does not replace\ncenter spacing, angles, radii, lengths, areas or volume measurements.\n\n## Overlap and clearance checks\n\n`overlap_volume(Solid, Solid) -> float` measures the exact intersection volume\nof two finite, positively oriented solids. It returns zero for mere contact.\nIt applies no minimum volume, exclusions, part hierarchy or pass/fail policy.\nKernel failures raise; inputs remain unchanged. Length units from STEP are mm,\nso overlap volume is mm³. Tolerances in these scripts are design decisions,\nseparate from the kernel's numerical precision.\n\nFor example, checking every body pair inside a chosen subassembly:\n\n```python\nfrom itertools import combinations\nfrom cadgen import read_scene\nfrom cadgen.geometry import overlap_volume\n\nscene = read_scene(\"STEP/assembly.step\")\n(selection,) = scene.resolve(\"#housing_assembly\")\nbodies = [\n    (entity.ref, solid)\n    for entity in selection.entities(\"shape\")\n    for solid in entity.shape().solids()\n]\nif len(bodies) < 2:\n    raise ValueError(\"This interference check needs at least two bodies\")\n\nmax_overlap_mm3 = 0.01  # chosen for this design\nfailures = []\ntested = 0\nfor (ref_a, a), (ref_b, b) in combinations(bodies, 2):\n    volume = overlap_volume(a, b)\n    tested += 1\n    if volume > max_overlap_mm3:\n        failures.append((ref_a, ref_b, volume))\nprint({\"tested_pairs\": tested, \"limit_mm3\": max_overlap_mm3,\n       \"overlaps_mm3\": failures})\nif failures:\n    raise SystemExit(1)\n```\n\nAdapt pair selection to the question: for clearance around a moving carriage,\ncompare that carriage against relevant fixed geometry. List intentional\npress fits/excluded pairs explicitly. Do not infer exclusions from assembly\ndepth. For large pair sets, retain the shapes and compute conservative bounds\nonce per body. Disjoint bounds rule out overlap; intersecting bounds only\nidentify candidates for the exact query. For a clearance requirement use\n`closest_points(a, b).distance` and\ncompare against the specified minimum. A static check proves only that pose;\npath or motion checks need an explicit sampling or swept-volume strategy.\n\n## Geometry diagnostics\n\n```python\nfrom cadgen.geometry import is_sound, topology_errors, boundary_edges, self_intersections\n\nok = shape.is_valid                       # bool: BRepCheck passes (build123d)\nclean = is_sound(shape)                   # bool: the boolean kernel's argument check passes, expensive\nissues = topology_errors(shape)            # tuple[GeometryIssue, ...]\nfree = boundary_edges(shell)              # tuple[Edge, ...]\ncrossings = self_intersections(shape)     # tuple[GeometryIssue, ...], expensive\n```\n\n`GeometryIssue` has `code` and `entities`, containing owned build123d geometry.\nTopology codes are OCCT `BRepCheck_*` statuses; self-intersection codes are\n`BOPAlgo_SelfIntersect`. Failed/inconclusive checks raise `GeometryError`,\nnever an empty success result. None of these functions repairs geometry.\n\n`is_sound` is `BRepAlgoAPI_Check`'s verdict: BRepCheck-valid, no\nself-intersections, no too-small edges, an argument type a boolean accepts.\nIt is the gate a fuse or cut demands of an operand. Closure, solid count and\nsigned volume stay the script's own checks. `is_sound` is `False` for a null\nor empty shape, which the boolean kernel rejects as an argument. A check costs\nkernel time, so gate where a failure is plausible rather than after every\noperation.\n\nChoose checks appropriate to the artifact. For an intended closed solid,\ncheck topology, free shell edges and each solid's signed volume. A reversed\nsolid can pass topology validation but have negative volume; check individual\nsolids because aggregate volumes can cancel. Open shells can be valid when\nsurfaces are intended. No free edges establishes closure, not full manifold\nvalidity. Run self-intersection checks explicitly when required and report\nany checks you did not complete.\n\n## Mass properties\n\n```python\nfrom cadgen.geometry import mass_properties\n\nproperties = mass_properties([\n    (aluminum_body, 2.70e-6),  # kg/mm³; use the material density for the task\n    (steel_insert, 7.85e-6),\n])\nprint(properties.volume, properties.mass, properties.center_of_mass)\nprint(properties.inertia)\n```\n\n`mass_properties(Iterable[tuple[Solid, float]]) -> MassProperties` integrates\nuniform density per solid and sums the supplied bodies, including overlaps.\nDensities must be finite and positive. It does not infer materials or fuse\nbodies. The 3×3 inertia tuple is about the combined center of mass in the input\ncoordinate axes. With mm and kg/mm³, outputs are mm³, kg, mm and kg·mm².\n\nUse snapshots for visual review per `snapshot-review.md`. Turn visual concerns\ninto explicit geometric checks before claiming they have been resolved.\n","skills/cad/references/kinematics.md":"# CAD kinematics and animation\n\nRead this file when the user asks to articulate, pose, or animate a STEP\nmodel, or when designing or reviewing mates, couplings, pose presets, posed\nexports, or animation clips.\n\nThere are THREE systems with different lifecycles, deliberately independent:\n\n- **Geometry** is the module's constants and the factory the parameterless\n  model calls with them (`WIDTH = 10.0` … `return _bracket(WIDTH)`). Changing\n  one re-runs Python and rebuilds the outputs. They are not live in the viewer.\n- **Kinematics** is typed mates declared as PURE DATA via `kinematics=` on\n  the export decorators. It drives the viewer's pose sliders — no rebuild, no\n  Python at render time — and never moves the geometry a model writes. It\n  lives in the model's sidecar (`<name>.step.json`, written beside the\n  artifact), alongside any intrinsic material appearance.\n- **Animation** is choreography declared as a self-contained JavaScript ES\n  module string via `animation=` on `@step`. The build embeds it in\n  `<name>.step.json`; the viewer, snapshot door, and animated GLB export read\n  that document-bound copy. Animated GLB export hashes the source with the clip\n  request, so an animation edit invalidates that export. Animation targets\n  occurrences directly and knows nothing about mates. Like materials and\n  kinematics, it never changes STEP geometry or bytes.\n\n## Kinematics: typed mates\n\nKinematics is one sidecar section, independent of intrinsic material appearance.\nIt never moves saved geometry: the declaration describes how the written tree\narticulates, and the viewer poses it at render time. A STEP model with no\nkinematics, intrinsic material finishes or animation has no sidecar.\n\nOne `kinematics=` dict, closed keys `mates` / `couplings` / `poses`, on any of\n`@step`/`@stl`/`@glb`/`@threemf`. Each decorator's declaration stands alone\n(share a module-level dict; there is no cross-decorator inheritance).\n\n```python\nimport cadgen\nfrom cadgen import step\nfrom cadgen import build123d as bd\n\nKINEMATICS = {\n    \"mates\": [\n        cadgen.revolute(\"elbow\", parent=\"#upper_arm\", child=\"#forearm\",\n                        origin=(0, 0, 0), direction=(0, 0, 1), limits=(0, 150)),\n        cadgen.revolute(\"wrist\", parent=\"#forearm\", child=\"#hand\",\n                        origin=(80, 0, 0), direction=(0, 0, 1), limits=(0, 90)),\n    ],\n    \"couplings\": [cadgen.couple(\"curl\", {\"elbow\": 1, \"wrist\": 0.5}, limits=(0, 90))],\n    \"poses\": {\"straight\": {\"curl\": 0}, \"bent\": {\"curl\": 60}},\n}\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS)\ndef arm():\n    upper_arm = bd.Pos(-40, 0, 0) * bd.Box(80, 10, 10)\n    forearm = bd.Pos(40, 0, 0) * bd.Box(80, 10, 10)\n    hand = bd.Pos(90, 0, 0) * bd.Box(20, 10, 10)\n    upper_arm.label, forearm.label, hand.label = \"upper_arm\", \"forearm\", \"hand\"\n    return bd.Compound(children=[upper_arm, forearm, hand], label=\"arm\")\n\n\nif __name__ == \"__main__\":\n    arm()\n```\n\n- **Mate kinds**: `revolute` (degrees about an axis), `slider` (model units\n  along it), `cylindrical` (sub-DOFs `<name>.turn` and `<name>.travel` about\n  one axis), `fastened` (0-DOF rigid attachment — needed exactly when\n  occurrences are SIBLINGS in the instance tree, like a pin that must orbit\n  with its carrier; instance-tree children ride for free).\n- **`limits=(lo, hi)`** is required on every mate that moves, and both ends\n  must be finite: Position sliders and exports read the range. A joint that\n  turns freely takes a full range such as `(-180, 180)`; `math.inf` fails the build.\n- **`parent`/`child`** are occurrence refs: `#`-prefixed labels (canonical —\n  label parts with `cadgen.label_shape`) or occurrence ids. They must resolve\n  at build or the build fails; `read_scene(path).leaves()` lists saved geometry occurrences.\n  A label resolves **into linked children**: a part labelled inside a\n  sub-assembly you call (`#shoulder_yaw_servo` living in `base_link()`'s\n  tree) resolves to its occurrence under the link (`o1.1.1`), so an assembly\n  can mate parts of a sub-assembly it links without owning their geometry.\n  A ref may name a SUBASSEMBLY as well as a part — a labelled group `Compound`\n  is an occurrence in the instance tree, and mating it carries every part\n  beneath it. That is how a rocker-bogie chain is three mates instead of three\n  hundred. `scene.roots` and `occurrence.children` expose those groups. Targets may NEST — a part inside a mated group may carry its\n  own mate to a sibling (a servo's output horn bolted to the jaw it drives):\n  the DEEPEST mate naming a part owns it and moves it exactly once, and the\n  enclosing group carries only what no deeper mate claimed.\n- **`axis`** is a selector ref (`axis=\"#forearm.f2\"` — a cylindrical\n  face or circular edge yields its axis, a planar face its center+normal) or\n  literals (`origin=(x, y, z), direction=(x, y, z)`). Refs resolve ONCE at\n  build into world numbers; the viewer does arithmetic, never topology.\n- **ZERO IS THE ARTIFACT AS WRITTEN.** Every DOF's rest value is 0 — the\n  placement the author built. There is no `default=`; a presentation pose is a\n  preset. A model that must be WRITTEN at another configuration is authored\n  at that configuration (or is another model): no decorator argument moves\n  geometry.\n- **`couple(name, {dof: ratio})`** declares a virtual DOF gearing real ones\n  linearly and ADDITIVELY (above, setting `curl=x` adds `x` degrees to `elbow`\n  and `0.5*x` to `wrist`).\n  Exact gear trains are ratio arithmetic, not code.\n  A geared member BACK-DRIVES in the viewer: when exactly one coupling gears a\n  DOF with a nonzero ratio, its Position slider reads the effective value\n  (own + ratio x coupling), is labelled \"driven by <coupling>\", and dragging it\n  moves the COUPLING — `coupling = (target - own)/ratio`, clamped to the\n  coupling's limits — so sliding one gear turns the whole train. A member's own\n  value (from a preset or `--kinematics`) is never overwritten, and a DOF geared\n  by two couplings stays independent: that inverse is underdetermined, so the\n  viewer refuses it rather than guessing a split.\n- A declaration needs at least one mate (or coupling): a pose is a set of joint\n  values and a joint is what a mate declares, so `poses` alone declare nothing\n  and are refused. A part with no joints declares no `kinematics=`.\n- **`poses`** are named `{dof: value}` presets — all that remains of \"pose\"\n  as a concept.\n- The mate graph is a TREE: one parent mate per occurrence, no cycles.\n  Closed-loop linkages (four-bars) are out of scope by design — they need a\n  solver; the viewer evaluates pure forward kinematics from the sidecar's\n  numbers at render time.\n\n## Annotating a STEP you did not generate\n\nA document with no model script gets its kinematics from\n`cadgen step build IN OUT`, whose `--kinematics` takes the whole SPACE — the\nsame `{mates, couplings, poses}` vocabulary, as inline JSON or a `.json`\npath. `--materials` accepts the named material declaration as inline JSON or\na `.json` path, and `--animation` accepts a self-contained JavaScript module\nfile or source string that exports only `clips` (see below). The input is read\nwith OCCT and re-emitted by the canonical writer, so OUT's bytes are\ndeterministic whichever kernel wrote IN:\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\n**Wrapper script or `step build`?** A model that will keep changing belongs in a\nscript — a thin `@step` function that imports the foreign STEP and re-exports\nit, so the kinematics live beside the geometry decisions and every edit is one\n`python model.py`. Reach for `step build` when the geometry is fixed and not\nyours: a one-shot annotation or canonicalization of a vendor file. Re-running it\nis a no-op, editing only these annotations refreshes the sidecar without\nre-emitting a byte, and vendor metadata (PMI, GD&T) does not survive the trip.\n\n## Animation: the embedded module\n\nA STEP document may carry one self-contained JavaScript animation module in\nits unified sidecar. Author the module as a Python string and pass it to\n`@step(animation=...)`. It exports `clips` and nothing else; leave helpers and\nconstants unexported. The renderer refuses a module with any other export, and\nevery clip with it, so the build refuses it first, in the renderer's words:\n`arm.py::arm animation: unknown export ease — the renderer understands: clips`.\nThe module has no imports. For the arm above, add this constant and update its\ndecorator, keeping the same model body:\n\n```python\nANIMATION = r\"\"\"\nexport const clips = {\n  demo: {\n    label: \"Demo\",\n    duration: 8,          // seconds\n    loop: true,           // default\n    update(t, m) {        // called every frame; t in seconds\n      const angle = 60 * (1 - Math.cos(2 * Math.PI * t / 8));\n      m.get(\"forearm\").rotate([0, 0, 1], angle, [0, 0, 0]);\n      m.get(\"hand\").rotate([0, 0, 1], angle, [0, 0, 0]);\n    },\n  },\n};\n\"\"\"\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS, animation=ANIMATION)\ndef arm(): ...\n```\n\n- `m.get(target)` takes a LABEL (canonical) or occurrence-id refs\n  (`\"#o1.3.1\"`, comma lists; each id covers its whole subtree). Unknown\n  targets THROW — a typo never silently animates nothing. Labels here match\n  RENDERED PARTS only: to animate a whole group, name its occurrence id.\n- Handles support `.rotate(axis, degrees, origin=[0,0,0])`, `.translate(vec)`,\n  `.opacity(0..1)` and `.visible(bool)`. Successive transforms premultiply:\n  spin about a part's center first, then orbit the origin, and the spin rides\n  the orbit. For flexible swept bodies, see\n  [tube deformation and morph export](animation-deformation.md).\n- Every frame starts from rest and `update(t)` rebuilds the state — a pure\n  function of t, so scrub/loop/seek are free. No wall-clock, no state.\n- Animation targets occurrences independently of mates. Rerun the model after\n  editing its animation to refresh the sidecar. Literal annotation edits may\n  reuse cached geometry; computed or imported annotations can require a rebuild.\n  See [annotation caching](step-generation.md#annotation-caching).\n- The model build validates the declaration, exports included, and embeds it.\n  A model without `animation=` is simply a model without animation.\n- Targets are checked at LOAD, against the compiled tree: every clip's\n  `update(0, m)` runs once when the module loads, and a label or occurrence\n  id no part carries is reported in the viewer's Issues (`Animation\n  unavailable`, in the file's panel) and in\n  `snapshot --animation`'s error — not at the first frame that reaches it.\n- Mesh-only models (no `.step`) have no document sidecar; animation is a\n  STEP-document concern.\n\n## Reviewing motion\n\nReview motion interactively in the viewer or render a clip video as described\nbelow. For a still of the arm example's configuration, render at DOF values:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics '{\"curl\": 60}'\n```\n\n`--kinematics` is named for the `kinematics=` block it drives, and takes\neither spelling: `{dof: value}` JSON, or the NAME of a pose the model\ndeclares under `poses`. A name is checked against the declaration, so a typo\nfails with the poses this model actually has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics bent\n```\n\nFor still evidence of a CLIP, freeze one frame: `--animation` names a clip\nthe document sidecar's embedded animation declares and `--time` the\nmoment in seconds (default 0). The frame\nis composed exactly as the viewer composes it: `--kinematics` sets the base\npose, and the clip's `update(t, m)` is evaluated at that time on top of it.\nA clip name the model does not declare fails with the clips it has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo_t2.png --animation demo --time 2.0\ncadgen step snapshot STEP/arm.step tmp/demo_bent.png --kinematics bent --animation demo --time 2.0\n```\n\nIn a JSON job the request is one field, `\"animation\": {\"clip\": \"demo\",\n\"time\": 2.0}`, beside `\"kinematics\"`; the Python door takes the same object\n(`step.snapshot(..., animation={\"clip\": \"demo\", \"time\": 2.0})`) or the clip\nname with `time=`.\n\n### Rendering the whole clip\n\n`--video` renders the SPAN instead of a moment, into the `.mp4` or `.gif` the\nOUT names. Everything else is unchanged — same unified display settings, camera\nand size profile as a still, and the same `--kinematics` base pose underneath:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo.mp4 --animation demo --video '{\"fps\": 30}'\ncadgen step snapshot STEP/arm.step tmp/demo.gif --animation demo \\\n  --video '{\"fps\": 12, \"seconds\": 3, \"start\": 1.5, \"quality\": \"draft\"}'\n```\n\nThe request's keys, all optional:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `fps` | `30` | frames per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to render |\n| `start` | `0` | seconds into the clip where the video begins; must be inside it |\n| `quality` | `review` | `draft`, `review`, or `high` |\n| `loop` | `true` | GIF only — an `.mp4` carries no loop count, so `\"loop\": false` on one is refused |\n\nThe span is measured against the clip rather than trusted, because the clip\nevaluator ANSWERS a time past the end instead of refusing it: a clip that does\nnot loop holds its final pose, so an overrunning span buys frames that are all\none still image, and a looping clip wraps, so it renders a different span than\nthe one asked for. A `start` at or past the end is refused; an explicit\n`seconds` that overruns a clip which does not loop renders and warns. Hence the\ndefault: a looping clip gets one whole cycle from wherever it starts, and a clip\nthat stops gets the part of it that is left. `fps * seconds` is capped at 7200\nframes — every frame is a full-size PNG on disk before ffmpeg runs.\n\nWidth and height come from `--width`/`--height`/`--size-profile` like any\nother render; there is no size key here. `--video` needs `--animation` (it\nrenders a clip) and refuses `--time` (that freezes one frame instead), and it\nneeds **ffmpeg** on `PATH` — or `CADGEN_FFMPEG` pointing at one. A missing\nencoder is reported before the first frame is drawn, never after minutes of\nrendering. A GIF stores its frame delay in hundredths of a second, so a player\nshows the nearest rate to the one asked for; `.mp4` is exact.\n\nThe camera is fitted ONCE, to everything the clip covers, so the model moves\nand the frame does not. That is why a long clip is worth trimming with\n`start`/`seconds`: framing the whole of a clip that travels a long way leaves\nthe interesting part small.\n\nIn a JSON job the request is a `\"video\"` object beside `\"animation\"`, and a\nvideo job carries exactly one output:\n\n```json\n{\n  \"input\": \"STEP/arm.step\",\n  \"animation\": { \"clip\": \"demo\" },\n  \"video\": { \"fps\": 24, \"quality\": \"high\" },\n  \"outputs\": [{ \"path\": \"tmp/demo.mp4\", \"camera\": \"iso\" }]\n}\n```\n\nThe result names the file and what it covers — `saved video: tmp/demo.mp4\n(120 frames, 30 fps, 4s)` — so a wrong clip or a wrong span shows up without\nopening it. Still snapshots remain the evidence for a POSE; a video is for\nmotion a still cannot show.\n\n### Exporting the clip INSIDE a GLB\n\nA video is pixels. `cadgen glb build --animation` writes the motion itself: the\nclip is sampled into glTF node animation, so whatever opens the `.glb` — Blender,\na three.js viewer, a browser's model preview — plays it. There is no camera, no\nquality and no encoder here, and `fps` means something different: the SAMPLING\nrate of the baked keyframes, not a playback rate.\n\n```bash\ncadgen glb build STEP/arm.step GLB/animated/arm.glb --animation demo\ncadgen glb build STEP/arm.step GLB/animated/arm.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\nThe request is a clip name, or an object whose keys are all optional but `clip`:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `clip` | — | the clip to bake; required |\n| `fps` | `30` | keyframe samples per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to bake |\n| `start` | `0` | seconds into the clip where the span begins; must be inside it |\n| `drop` | `[]` | effects to bake STATIC instead of refusing: `opacity`, `visible` |\n| `deform` | `refuse` | what to do with `.deformTube()`: `refuse`, `morph`, `rest` |\n| `deformTolerance` | `1.0` | `morph` only — millimetres the baked tubes may sit from the clip's own deformation, `0.01`..`10` |\n\nThe span is resolved exactly as `--video`'s is — a looping clip defaults to one\nwhole cycle, a clip that stops gets what is left of it, and `fps * seconds` is\ncapped at 7200 samples. The exported animation is re-based to zero, so `start`\npicks where in the CLIP the span begins and the file still opens at t = 0.\n\n**What glTF carries, and what it will not:**\n\n| clip effect | in the GLB |\n| --- | --- |\n| `.rotate()` | sampled rotation channel on that occurrence's node |\n| `.translate()` | sampled translation channel on the same node |\n| `.rotate()` about a pivot | sampled rotation and translation channels |\n| `.opacity()` | **refused.** glTF has no animated opacity. `\"drop\": [\"opacity\"]` bakes the value at `start` as a material alpha, and warns |\n| `.visible()` | **refused.** Same reason. `\"drop\": [\"visible\"]` omits whatever is hidden at `start`, and warns — an occurrence dropped this way loses its motion too, because a node that is not in the file cannot be animated |\n| `.deformTube()` | **refused by default.** Per-vertex motion, not a node transform. `\"deform\": \"morph\"` bakes it as glTF morph targets; see [deformation](animation-deformation.md); `\"deform\": \"rest\"` ships those tubes at rest shape and warns |\n\nNothing is dropped quietly: an effect the file cannot carry stops the export and\nnames the occurrences, so a hand whose tendons froze on the way out is a refusal\nrather than a finished-looking file. Render the clip with\n`cadgen step snapshot --animation <clip> --video` when the motion is one of\nthose — `--video` needs the clip named too, so both flags go together.\n\nFor `.deformTube()` authoring, morph fitting, memory limits and braid export\nlimitations, read [tube deformation and morph export](animation-deformation.md).\n\nThe CAD Viewer plays a GLB's embedded rigid, skinned and morph animation from its\nfile sidebar's Animation section, in the tools view and Preview alike. These are baked clips: the STEP\nsidecar module's procedural controls are not available in the exported GLB.\n\nAn animated export writes ONE node per occurrence instead of the flat,\ncolour-grouped mesh a static one writes, so the file is bigger and its parts are\nindividually addressable. Only occurrences the clip actually MOVES get channels;\nan occurrence held at a constant offset carries it on its node and nothing else.\nFreshness folds the clip request and the sidecar animation source into the export's key, so\nediting the choreography re-exports even though the STEP has not changed.\n\n`--animation` is GLB's alone, and the CLI is generated from each door's\nsignature, so `cadgen stl build` and `cadgen 3mf build` have no such flag at all\n— they exit 2 with `unrecognized arguments: --animation`. Neither format has\nanywhere to put a clip; export it as `.glb`, or render it with\n`cadgen step snapshot --animation <clip> --video`.\n\nAnimated GLB requires an explicit OUT. A static export without OUT writes one\nsibling `.glb` beside the STEP; it does not read model output declarations.\nChoose a separate destination for the animated file when retaining both.\n\nChoose pivots and axes for the intended motion. For hinged motion, use the\nphysical hinge axis. Verify the relevant dimensions, alignments and clearances\nwith geometry checks.\n","skills/cad/references/migrations.md":"# Migrations\n\nRead this file when source, command syntax or sidecars target a different\ncadgen version. First run `cadgen doctor <skill-dir>` to check the installed\nversion against the skill's pin. Follow the specific error and current command\nhelp; a modeling failure alone does not establish version skew.\n\ncadgen uses hard interface cutovers. A retired interface may fail with a\nteaching error naming its replacement; it is not a compatibility alias.\nThe retired inspect CLI is replaced by Python checks using `read_scene` and\nnative geometry; see [inspection](inspection-and-validation.md).\n\n## A migration message is a task\n\nWhen a build, snapshot or validation reports that a model needs\nmigrating (a sidecar refused for its schema version, a leftover companion file\nthe build warns about, a retired decorator argument or command named by a\nteaching error), do the migration then, as part of the work in hand. Do not\nroute around it, silence it, or leave it for later: the CAD Viewer does not\nflag a stale model, so an unmigrated one simply loses its kinematics,\nmaterials and animation and looks like a plain part.\n\n- A refused sidecar (`unsupported sidecar schema N (expected M)`) is fixed by\n  running the model's script again (`python <model>.py`), which writes a current\n  sidecar beside the STEP. For an imported STEP that has no script, re-annotate\n  it with `cadgen step build`. Never edit `schemaVersion` by hand.\n- A leftover companion render module beside the STEP (a `.js` file named after\n  the document; the build warns it \"is a retired render module\") is read by\n  nothing, so its clips are missing from the model. Move them into\n  `@step(animation=...)` ([kinematics](kinematics.md)), delete the file and\n  rebuild.\n- After migrating, confirm it took: the sidecar declares the current schema and\n  the document's hash, the build printed no migration warning, and the model\n  articulates (a `cadgen step snapshot --kinematics …` pose differs from rest,\n  or the Viewer shows a Position section in the file's panel, the Position\n  tool and, for a model with routines, its routines playing in preview).\n- If the script itself no longer runs on the installed cadgen, migrate the\n  script using the sections below, then rebuild.\n\n## When to suspect skew\n\n- **A model script runs, exits 0, and writes nothing.** An older source carries\n  no decorated function and no entry point of its own, so Python defines a\n  function and exits. Nothing looks for an entry point by name.\n- **A command or flag you are sure of comes back unknown**, and the help lists\n  an unfamiliar set. Building a model is running its script; there is no\n  generation verb. Consult current help and any replacement named by the error.\n- **A sidecar is refused for its schema version.** Sidecars are never upgraded\n  in place and never partially read, because a wrong-shaped one would cost a\n  model its kinematics silently.\n- **A model that used to articulate renders inert**, presenting as a plain\n  document with no pose and no animation. Nothing is discovered by convention:\n  kinematics and animation are `kinematics=` and `animation=` on the model's\n  decorator, and the build puts both in the document's sidecar. A companion\n  JavaScript file beside the document is no longer read. A generated model's\n  build warns about the leftover file and names the replacement decorator;\n  the warning does not stop the build.\n- **Meshes come out visibly coarser or finer, with no error.** Mesh tolerance\n  kept its name and changed meaning — chord tolerance is a fraction of the\n  component's bounding diagonal, not an absolute length — so a value carried\n  across from an older project is wrong in proportion to the part's own size.\n  A carried-over value above `0.05` is refused outright with the conversion\n  (X mm on a part whose diagonal is D mm is X/D); a smaller one is accepted, so\n  check it against the part's size.\n\nA migrated source may still have incompatible saved outputs. Rebuild or\nre-annotate the affected document as the error directs; preserve imported\nsources and do not delete unrelated artifacts to diagnose a version mismatch.\n\n## Migration guides\n\n- **cadgen 0.4 → 0.5** — generator functions became decorated model scripts, the\n  generation CLI was removed, sidecars and provenance moved, snapshot job JSON\n  was re-keyed, and mesh tolerance became relative.\n  https://github.com/earthtojake/text-to-cad/blob/main/docs/migrations/migrating-0.4-to-0.5.md\n","skills/cad/references/positioning.md":"# Assembly positioning and mating\n\nRead this file when placing assembly parts, defining mating datums or checking\nalignment. Author placements in the model source, then check the saved geometry.\nChoose explicit transforms or native build123d joints according to which makes\nthe intended relationship easiest to express and maintain. Neither requires a\nparticular assembly size or complexity.\n\n## Transforms and local frames\n\nUse functional datums and explicit dimensions for placements. A part's origin\nmight be its mounting interface, symmetry center or rotational axis; use the\nconvention that fits the design. Record it when another part depends on it.\n\n```python\n# Inside an assembly model:\nleft = bd.Pos(-pitch / 2, 0, height) * spacer()\nleft.label = \"spacer_left\"\nright = bd.Pos(pitch / 2, 0, height) * spacer()\nright.label = \"spacer_right\"\nassembly = bd.Compound(children=[left, right], label=\"spacer_pair\")\n```\n\nPlace child models with `.moved()` or `Pos/Rot/Location * child` to preserve\nshared geometry. `.located()` replaces the existing placement and copies the\ngeometry; it loses the cached child link. Labels identify roles and repeated\noccurrences, such as `m3_screw:front_left`. A functional group can be a nested\nlabeled `Compound` when the design benefits from that hierarchy.\n\nMirroring changes geometry, so an inline mirrored child belongs to the parent\nrather than linking to the original child's tree. It remains cached with that\nparent. A separate mirrored model is useful for independent outputs or reuse,\nnot a requirement; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\n\n## Native joints\n\nJoints can express a relationship between reusable datums without hand-solving\nits placement. They perform source-level placement; they are not persistent\nSTEP constraints or a replacement for the separate [kinematics](kinematics.md)\ninterface. Call `connect_to()` on the fixed joint with the moving joint as its\nargument.\n\nFor example, `src/enclosure.py` places a lid above a base with a specified gap:\n\n```python\nfrom cadgen import build123d as bd, step\n\nBASE_HEIGHT = 30.0\nLID_THICKNESS = 3.0\nGASKET_GAP = 0.5\n\n\n@step(out=\"../STEP/enclosure.step\")\ndef enclosure():\n    base = bd.Box(80, 50, BASE_HEIGHT)\n    lid = bd.Box(80, 50, LID_THICKNESS)\n    base.label, lid.label = \"base\", \"lid\"\n    bd.RigidJoint(\n        \"lid_target\", to_part=base,\n        joint_location=bd.Location((0, 0, BASE_HEIGHT / 2 + GASKET_GAP)),\n    )\n    bd.RigidJoint(\n        \"underside\", to_part=lid,\n        joint_location=bd.Location((0, 0, -LID_THICKNESS / 2)),\n    )\n    base.joints[\"lid_target\"].connect_to(lid.joints[\"underside\"])\n    return bd.Compound(children=[base, lid], label=\"enclosure\")\n\n\nif __name__ == \"__main__\":\n    enclosure()\n```\n\nNative joint options include `RigidJoint` for fixed placement, `RevoluteJoint`\nfor rotation, `LinearJoint` for translation, `CylindricalJoint` for combined\nrotation/translation and `BallJoint` for spherical orientation. Use `Location`\nfor rigid/ball joint frames and `Axis` for revolute/linear/cylindrical joints.\n\nCreating joints reads the child's placement and may materialize it earlier\nthan a deferred transform. Cached child models return geometry, labels,\nappearance and placements; do not rely on Python joint objects surviving a\nchild's cache round trip. Define the joints needed by the parent in its source.\n\n## Child models and imported components\n\nCall a project model to compose it. Use `cadgen.read_step` for a vendor document\nor an explicitly decoupled export; like every file a build reads, it is an input.\nSee the [model contract](step-generation.md) for dependency tracking.\n\nFor imported geometry, inspect its existing origin, orientation and functional\nfeatures with `read_scene` before choosing datums. Express measured offsets\nor joint frames in the source, then validate the placed result. Anchor model\ninput paths on `__file__` when they must work from any working directory.\n\n```bash\npython path/to/assembly.py\npython tmp/check_assembly.py\n```\n\n## Alignment and measurement checks\n\nSelect the actual mating features from `read_scene`, then express the intended\nrelationship in Python. There is no generic alignment mode that guesses which\npoints or axes the design means. See `inspection-and-validation.md` for the\nreader and native measurement interfaces.\n\nFor example, for two planar mating faces (with their refs already identified):\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"STEP/assembly.step\")\n(moving_face, fixed_face) = scene.resolve(\"#moving.f1,fixed.f2\")\nmoving, fixed = moving_face.shape(), fixed_face.shape()\nn = fixed.normal_at().normalized()\nm = moving.normal_at().normalized()\nsigned_gap_mm = (moving.center() - fixed.center()).dot(n)\nparallel_error_deg = min(n.get_angle(m), n.get_angle(-m))\nprint(signed_gap_mm, parallel_error_deg)\nassert abs(signed_gap_mm) < 0.01\nassert parallel_error_deg < 0.1\n```\n\nThese thresholds are illustrative; use the task's tolerances. The plane gap\nand parallelism test does not establish lateral alignment or overlapping face\nfootprints. Check those when required. For opposed normals, assert the signed\norientation explicitly instead of accepting either parallel direction.\n\nFor a screw-pattern dimension, compare the analytic centers of the selected\ncircular edges. For clear space between bodies use `closest_points`.\n`shape.bounding_box().size` measures the world-aligned envelope. Use explicit\nvectors and datums for orientation, signed offsets and center spacing.\nUpdate source placements, regenerate, and rerun the relevant checks.\n\nCorrect failed positioning in the model source, then regenerate and rerun the\nrelevant checks. Report measured relationships and any intended alignment left\nunchecked; no positioning report is needed when positioning is irrelevant.\n","skills/cad/references/project-layout.md":"# CAD project structure\n\nFollow an existing project's layout. For a new multi-model project, use the\nstructure below; a one-off part can use a flat folder. In a larger workspace,\nput CAD work in its existing `models/`, `cad/` or `hardware/` area. In a bare CAD\nworkspace, use the root. cadgen does not infer paths from folder names: each\nmodel declares its outputs.\n\n## Source and output mapping\n\n**One model entrypoint per `.py` file, with matching output stems.** For\nexample, `src/bracket.py` contains one parameterless decorated model and declares\n`STEP/bracket.step`, `STL/bracket.stl`, `GLB/bracket.glb` or `3MF/bracket.3mf`, as\nneeded. Stacking output decorators still declares one model. Helpers and package\n`__init__.py` files are not model entrypoints.\n\n```\n<project>/\n  src/\n    README.md             # model catalog\n    plate.py              # one model per entrypoint\n    plate_drawing.py       # separate drawing model\n    assembly.py           # root assembly\n    chassis/              # optional grouping as the project grows\n      __init__.py\n      frame.py\n      strut.py\n    purchased/            # optional read_step wrapper models for vendor parts\n      servo.py\n    lib/                  # shared helpers and parameterized factories\n      __init__.py\n      holes.py\n  STEP/                   # raw artifacts and generated sidecars\n    plate.step\n    chassis/\n      frame.step\n      strut.step\n    imported/             # external source files\n  DXF/  STL/  GLB/  3MF/  # other requested output formats\n  checks/                 # reusable checks, or the existing test directory\n  tmp/                    # ignored exploratory scripts, snapshots and debug output\n```\n\nKeep code and notes outside the format folders. Mirror nested source paths in\noutput folders so repeated names remain unambiguous:\n`src/chassis/frame.py` → `STEP/chassis/frame.step`. For several mesh densities\nof the same model, retain the stem in separate variant subfolders, such as\n`STL/draft/bracket.stl` and `STL/print/bracket.stl`.\n\nDecorator `out=` paths are relative to the script. For `src/plate.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\nRun `python src/plate.py` from the project root. An unchanged model is a no-op.\nThe CAD Viewer catalogs the saved artifacts; the source catalog identifies what\nto build before those files exist.\n\nPut exploratory inspection scripts in ignored `tmp/` or system `/tmp`, and\nretained checks in `checks/` or the project's test directory. See\n[inspection](inspection-and-validation.md) for examples.\n\n## Imports and shared code\n\nUse ordinary Python imports. Flat scripts can import sibling models and\n`lib/` directly. For nested scripts, declare the import root with `PYTHONPATH`;\ncadgen adds no paths of its own. From the project root:\n\n```bash\nPYTHONPATH=src python src/chassis/frame.py\n```\n\nPython packages and relative imports are supported. With\n`src/chassis/__init__.py`, run the model as a module when it uses relative imports:\n\n```bash\nPYTHONPATH=src python -m chassis.frame\n```\n\nDirect execution puts the script's directory first on the import path. Avoid a\nfilename that shadows a package it imports: `frame/frame.py` can hide the `frame`\npackage when run by path. Use a distinct entrypoint name or module execution.\n\nKeep shared factories, constants and helpers in `src/lib/` or an existing Python\npackage. Subsystems may import shared models or each other where the dependency\nmakes sense; avoid circular imports. Alias a module when importing a same-named\nmodel function would otherwise shadow it.\n\nDependencies are tracked as **models by result, constants by value, functions by reach**:\n\n```python\nfrom lib import fasteners  # helper module: tracked by what the model can run in it\nfrom plate import WIDTH    # literal from a model module: tracked by value\nfrom plate import plate    # model: calling it pins its result\n```\n\n`cadgen store why src/<model>.py` explains freshness. Importing a model never\nbuilds it; calling it inside an assembly does. See the\n[model contract](step-generation.md#models-inside-a-package) for package execution\nand dependency details.\n\n## Assemblies and model boundaries\n\nA parent calls its child models inside its body. The runtime builds stale children\nor reuses their cached results and links them into the parent: **running the root is the\nwhole build**. Run `python src/assembly.py`; rebuilding a child alone\ndoes NOT rebuild the assemblies that use it, so rerun the parent to incorporate\nthe change.\n\nGive a subassembly its own model when it needs independent outputs, reuse or a\nseparate cache boundary. Local construction can remain inline or in an ordinary\nhelper. A helper executes within its caller; a child model has its own record\nand reusable result. Separate folders are useful as the project grows, but a\nsubassembly does not require one. Group related files for navigation without\nforcing the directory tree to reproduce every assembly level.\n\nMirror inline for geometry local to a model; use separate left/right models\nwhen independent outputs or reuse justify them. See\n[mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\nA print-only part can declare `@stl`, `@glb` or `@threemf` without `@step` and\nstill compose into an assembly.\n\nUse factory arguments for configurations, with one entrypoint per independently\nexported model. Geometry must not depend on untracked environment variables,\nworking-directory state, time or random values; the cache cannot detect those\nchanges. The files a model reads are tracked on their own (the\n[model contract](step-generation.md)).\n\n## Naming\n\n- Use an importable Python identifier for the model script's stem, and retain\n  that stem in its declared outputs. Put an external part number in a label or\n  catalog entry if it cannot serve as the shared filename stem.\n- A drawing is a separate model: `plate_drawing.py` → `DXF/plate_drawing.dxf`.\n- Avoid filenames distinguished only by case; many filesystems ignore case.\n- Preserve upstream filenames for imported sources under the format's\n  `imported/` folder. They are inputs, not generated model outputs, and do not\n  need a matching script.\n\n## Renaming or retiring outputs\n\nChanging `out=` or deleting a model does not remove its previous artifacts.\nBefore a rename, record the old output paths, any `.step.json` sidecar and\ndeclared meshes. Rename the entrypoint and its matching outputs, rebuild and\nverify the new files, then remove only the obsolete generated files by exact\npath. Preserve imported sources and unrelated artifacts.\n\n## Building many models\n\nRunning a root builds its dependencies. For independent outputs, run the\ncatalog's entrypoints; independent roots can run concurrently, subject to the\nruntime's CPU and memory admission. Avoid duplicate simultaneous exports of the\nsame document. Batch snapshot views in one JSON job when they share scene settings.\n\nWhen several agents work on an assembly, independent subsystem entrypoints let\nthem build and verify their work before the root incorporates it.\n\n## Model catalog\n\nFor a multi-model project, keep `src/README.md` short and update it when the\nentrypoints or outputs change:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Mounting plate |\n| plate_drawing.py | DXF/plate_drawing.dxf | Plate flat pattern |\n| assembly.py | STEP/assembly.step | Plate with standoffs |\n\nBuild: `python src/assembly.py` from the project root builds the assembly and\nits dependencies. Run independent entrypoints separately. Unchanged models are current.\nImported source: STEP/imported/servo.step.\n```\n\n## Version control\n\nFollow the repository's existing ignore, artifact and Git LFS policies, and the\nuser's commit instructions. For a new project, a useful default is to retain\nauthored code, reusable checks and irreplaceable inputs, while ignoring\nreproducible outputs and scratch files. Keep pinned fixtures or deliverables\nwhen the project requires them; kernel upgrades can change bytes without\nchanging geometry.\n\nExample ignore patterns for that default:\n\n```gitignore\n/STEP/*\n!/STEP/imported/\n/DXF/*\n!/DXF/imported/\n/STL/*\n!/STL/imported/\n/GLB/*\n!/GLB/imported/\n/3MF/*\n!/3MF/imported/\n/tmp/\n__pycache__/\n```\n\nThe `*` forms allow Git to descend into the format folders and honor the\n`imported/` exceptions. Git LFS is optional unless the repository requires it;\nuse it for large binary inputs when appropriate. See the\n[starter](project-template.md#project-bookkeeping) for an LFS example.\n\n## Scaffolding a new project\n\nChoose a [minimal starter](project-template.md): a single part or a small\nassembly. Add other formats, helpers, groups and imported files only as needed.\nBuild the selected entrypoint, check its geometry and review its snapshot.\n","skills/cad/references/project-template.md":"# Minimal project starters\n\nChoose the starter that fits the task. Create only the files it needs; rename\nparts and change dimensions for the design. Keep an existing project's layout.\nFor a new project, use the location and conventions in [project layout](project-layout.md).\nCommands below run from the CAD project root.\n\n## One part\n\nCreate `src/plate.py`:\n\n```python\n# src/plate.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 60.0\nDEPTH = 40.0\nTHICKNESS = 4.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    body = bd.Box(WIDTH, DEPTH, THICKNESS)\n    body.label = \"plate\"\n    return body\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\n```bash\npython src/plate.py\ncadgen step snapshot STEP/plate.step tmp/plate.png\n```\n\nReview the PNG and check the dimensions needed by the design. A second model\nrun should report `current`. A one-off part can instead use a flat folder and\n`@step` with its default sibling output.\n\n## An assembly\n\nStart with the plate above and add `src/standoff.py`:\n\n```python\n# src/standoff.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nHEIGHT = 12.0\nOUTER_D = 8.0\nBORE_D = 3.4\n\n\n@step(out=\"../STEP/standoff.step\")\ndef standoff():\n    return bd.Cylinder(OUTER_D / 2, HEIGHT) - bd.Cylinder(BORE_D / 2, HEIGHT)\n\n\nif __name__ == \"__main__\":\n    standoff()\n```\n\nThen `src/assembly.py` places two instances on the top of the centered plate:\n\n```python\n# src/assembly.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom plate import THICKNESS, plate\nfrom standoff import HEIGHT, standoff\n\nPITCH = 30.0\n\n\n@step(out=\"../STEP/assembly.step\")\ndef assembly():\n    base = plate()\n    base.label = \"plate\"\n    post = standoff()\n    z = (THICKNESS + HEIGHT) / 2\n    left = bd.Pos(-PITCH / 2, 0, z) * post\n    left.label = \"standoff_left\"\n    right = bd.Pos(PITCH / 2, 0, z) * post\n    right.label = \"standoff_right\"\n    return bd.Compound(children=[base, left, right], label=\"assembly\")\n\n\nif __name__ == \"__main__\":\n    assembly()\n```\n\n```bash\npython src/assembly.py\ncadgen store why src/assembly.py\ncadgen step snapshot STEP/assembly.step tmp/assembly.png\n```\n\nThis is a placement example; add the fastening features the actual design needs.\nRunning the root builds its stale children. After a child changes, rerun the root.\nPlaced children use `.moved()` or `Location * shape` to keep their geometry shared.\nFor mating datums and joint relationships, see [positioning](positioning.md).\n\n## Add capabilities only when needed\n\n| Need | Add |\n| --- | --- |\n| STL, 3MF or GLB output | A mesh decorator; a mesh-only model omits `@step`. See [exports](supported-exports.md). |\n| Shared factory or hole pattern | A plain helper under `src/lib/`, with `src/lib/__init__.py`. |\n| Left/right geometry | Mirror inline, or use separate models for independent outputs/reuse; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models). |\n| Subassembly | A model that calls its child models; use folders as the project grows. |\n| 2D drawing | A separate drawing model using `$dxf`. |\n| Vendor CAD | Preserve the source under the format's `imported/` folder; use `read_step` with an anchored path or a wrapper model. |\n| Durable requirement checks | `checks/` or the project's existing test directory; exploratory checks and images go in ignored `tmp/`. |\n\nOnly kinematics, intrinsic materials or animation require a STEP sidecar.\nDeclaring a mesh alongside STEP does not create one.\n\n## Project bookkeeping\n\nAdd a short `src/README.md` catalog for a multi-model project:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Base plate |\n| standoff.py | STEP/standoff.step | Repeated spacer |\n| assembly.py | STEP/assembly.step | Plate with two spacers |\n\nBuild the assembly with `python src/assembly.py` from the project root.\n```\n\nFollow the repository's version-control policy and the user's commit instructions.\nThe [layout reference](project-layout.md#version-control) suggests defaults and\nignore patterns for new projects. Create output folders and helpers only as needed.\n\nIf the project uses Git LFS for imported sources, scope its `.gitattributes`\nentries to the relevant files or formats, for example:\n\n```gitattributes\nSTEP/imported/** filter=lfs diff=lfs merge=lfs -text\n*.step.json text\n```\n\nConfigure Git LFS before adding files covered by those rules. A file starting with\n`version https://git-lfs...` is a pointer: `git lfs checkout STEP/imported`\nrestores objects already in the local cache; missing objects need fetching from\nthe project's LFS remote before CAD tools can read them.\n","skills/cad/references/repair-loop.md":"# Repair loop\n\nUse the failing command and geometry to localize the problem, correct the\nresponsible source or arguments, then rerun the failed and affected checks.\nPreserve specified features and dimensions; disclose any necessary deviation.\n\n## Source and build failures\n\n- **Syntax or import error:** use the traceback to correct the source or\n  dependency. Keep geometry creation inside model functions and their helpers.\n- **Script exits without outputs:** confirm `__main__` calls the decorated model,\n  that it returns a native shape, and that its decorators declare the intended\n  outputs. Function names are not discovered by convention.\n- **Unexpected freshness:** run `cadgen store why <model>.py`; check tracked\n  inputs and whether the parent was rerun after a child changed. See the\n  [model contract](step-generation.md#freshness-cadgen-store-why).\n- **Installation or version mismatch:** run `cadgen doctor <skill-dir>` and\n  follow the specific error. See [migrations](migrations.md).\n\n## Geometry failures\n\n| Symptom | Useful checks and possible remedies |\n| --- | --- |\n| Missing or invalid body | Check profile closure, cut placement, zero thickness and the first failing operation. Use the Python diagnostics in [inspection](inspection-and-validation.md). |\n| Missing hole/pocket | Check feature mode, selector, cut depth and the intended through-condition against the saved geometry. |\n| Wrong scale or extents | Check units, radius versus diameter, primitive alignment and extrusion direction; measure the relevant dimensions. |\n| Fillet/chamfer failure | Check edge selection and local space. Consider feature order or equivalent profile construction. Change a required radius only as an explicit design decision. |\n| Loft failure or distorted surface | Inspect wire correspondence, winding, self-crossings and disconnected sections. Prefixes or adjacent pairs can localize the problem; a successful pair does not prove a valid full loft. |\n| Slow Boolean | Time the suspect operation or inspect a stack sample. Consider simpler surfaces, tool extents, batching or staged cuts while preserving the required feature. |\n| Selector no longer matches | Reopen with `read_scene`, enumerate candidates, and measure their geometry. Numeric refs are revision-scoped. |\n| Assembly placement mismatch | Check local datums, fixed/moving order, axis direction and transform composition. Measure the required signed gaps and angles on saved geometry. |\n\nVersion-specific pitfalls and construction alternatives are in\n[build123d modeling](build123d-modeling.md). For assembly corrections, see\n[positioning](positioning.md).\n\n## Viewer and snapshots\n\nShow the result as in [Show the model](../SKILL.md#show-the-model). With the CLI viewer,\n`cadgen viewer --help` lists its options. If showing fails, report the failure and use\nPython geometry checks plus snapshots.\n\nSnapshot commands take saved documents, not model scripts. Run the model first\nwhen its output is missing; otherwise verify the input format/path, Chromium\ninstallation and the reported options. Missing derived render data is compiled\non demand; adjacent GLB/topology files are not a prerequisite. Try a single\nsupported view when isolating a request error, then restore the views needed\nfor the review. See [snapshot review](snapshot-review.md).\n\n## Check the repair\n\nWhen a repair could affect other geometry, compare before/after documents using\n`read_scene` and the dimensions, volumes or relationships that should remain\ninvariant. Resolve labels independently in each revision. For shape changes,\nnative Boolean differences can show both added and removed material.\n\nReport what failed, what was repaired and verified, and which requirements\nremain untested or unresolved. Identify any artifact that remains usable.\n","skills/cad/references/snapshot-review.md":"# Snapshot review\n\nRead this file when choosing visual checks for saved STEP/STP or mesh outputs.\nUse `cadgen step snapshot` for STEP, or the corresponding `stl`, `3mf` or `glb`\nsnapshot command for meshes (see [mesh exports](supported-exports.md)). The\nCAD topology, selection and motion controls below apply to STEP; clipping applies\nto every 3D input, and exploding to a STEP or a GLB of several parts.\n\n## Policy\n\nEvery created or visibly updated part or assembly gets at least one reviewed\nPNG snapshot of its STEP, or its mesh when no STEP is declared. Passing geometry\nchecks does not waive this visual review. Use the format's snapshot command.\nFor a STEP pose or clip frame, pass `--kinematics` and/or\n`--animation CLIP --time SECONDS`. A motion review may additionally need video;\nsee [kinematics](kinematics.md#reviewing-motion).\n\nSkip saved snapshots only when no visible geometry was created or updated, or no valid artifact exists:\n\n- pure format/export requests where geometry is unchanged\n- source changes that do not alter visible geometry\n- inspection-only tasks (for example direct measurement questions) that create or update nothing\n- failed Python or STEP generation before a valid artifact exists\n\nWhen skipping, report the reason and the deterministic evidence that still ran.\n\nDo not loop on snapshots. Rerender only when a source repair changed visible geometry or when a specific visual finding needs confirmation.\n\n## Packet sizing\n\nChoose views that expose the features being checked. One may be enough;\nadd an opposing view for hidden exterior features, an orthographic view for\na pattern or silhouette, or a clipped view for internal geometry. No fixed set of\nviews proves every face or feature is correct.\n\n## Small packet\n\nFor example, a four-view comparison can use one JSON job:\n\n```json\n{\n  \"input\": \"models/part.step\",\n  \"mode\": \"view\",\n  \"outputs\": [\n    { \"path\": \"/tmp/render/iso.png\", \"camera\": \"iso\" },\n    { \"path\": \"/tmp/render/iso_opposite.png\", \"camera\": { \"direction\": [-1, 1, -0.8] } },\n    { \"path\": \"/tmp/render/top_ortho.png\", \"camera\": \"top\" },\n    { \"path\": \"/tmp/render/front_ortho.png\", \"camera\": \"front\" }\n  ],\n  \"output\": { \"viewLabels\": true, \"padding\": 0.12, \"sizeProfile\": \"diagnostic\" }\n}\n```\n\nUse only the views relevant to the design. Opposed isometric views reveal more\nexterior faces, but neither they nor orthographic views reveal all occluded geometry.\n\nSet `input` to the saved STEP/STP artifact using a relative or absolute path\n(documents only: run a `.py` model first). With no explicit display settings,\nsnapshots use the `solid` preset, Light appearance and an orthographic isometric\ncamera. Every mode defaults to 1600x1200 (the `diagnostic` profile). Job `mode`\nis `view` or `list`; display presets are a separate choice.\n\n`--size-profile` (`output.sizeProfile` in a job) is one of exactly these names;\nanything else is refused with this list:\n\n| Profile | Size |\n| --- | --- |\n| `simple` | 1200x900 |\n| `simple-square` | 1024x1024 |\n| `diagnostic` (default), `labeled` | 1600x1200 |\n| `assembly` | 1800x1200 |\n| `assembly-large` | 1920x1440 |\n| `presentation`, `contact-sheet` | 2400x1600 |\n| `presentation-large` | 2800x1800 |\n\n`--width`/`--height` (an output's `width`/`height` in a job) override the profile\nwith a whole number of pixels from 1 to 8192; a larger request is refused rather\nthan clamped. With `--job` they size every output in the packet.\n\n`--display` takes one of the Viewer's six Display modes, each drawn at its own\ndefaults: `solid` (the default: shaded, with edge linework), `shaded` (the same\nsurfaces, no edges), `parts` (Solid with each part its own colour), `xray`\n(translucent surfaces with occluded edges visible), `hidden-line` (linework with\noccluded edges suppressed), and `wireframe` (edges only); they use the shared\nprojection, orthographic by default. `--grid` draws the measuring grid (red X and\ngreen Y axes) under the model in any mode; it is off by default. `--preview` draws\nPreview's photographic look instead, in perspective with studio lighting, at\nPreview's defaults; it draws no grid and is view mode only.\n\n```bash\ncadgen step snapshot STEP/part.step tmp/review.png --display hidden-line --grid\ncadgen step snapshot STEP/part.step tmp/review.png --preview\ncadgen step snapshot STEP/part.step tmp/review.png --display display.json --camera front\n```\n\n`--display` (and a job's `display`) also takes the Viewer's display object, inline\nor as a JSON file: `mode`, boolean `grid`, `appearance` (`light`, the CLI default,\nor `dark`), `camera` (`projection`: `orthographic` or `perspective`; `focalLength`:\n20–200 mm), and the shared tools `clip` and `exploded`. A mode has no settings of\nits own, and anything else is refused.\n\n`--camera` (or a job's top-level or per-output `camera`) sets pose and framing\n(`preset`, `position`, `target`, `up`, `direction`, `zoom`,\n`orthographicHalfHeight`); projection and focal length belong in\n`display.camera`. Every 3D input takes `clip` (`at` in the file's own unit), and\n`exploded` takes a STEP or a GLB of several parts. `shaded`, `xray`, `hidden-line`\nand `wireframe` are STEP-only; meshes and robot descriptions take `solid` or\n`parts`, `--grid`, or `--preview`. DXF uses `--appearance` and has no scene display\nmodes.\n\n`camera` is a preset (`front`, `back`, `left`, `right`, `top`, `bottom`, `iso`),\nan `azimuth:elevation` pair, or a camera object. `output` takes `sizeProfile`,\n`padding` (0–0.15), `renderScale` (1–3) and the booleans `viewLabels`,\n`tightFrame` and `transparent`. Top-level `timeoutSeconds` is a positive number\nand `scale` is `cad` or `urdf`. Unknown keys and out-of-range values are refused.\n\nFor close macro views in normal CAD, a JSON job can set `quality.tessellation` to\n`{\"chordTolerance\": 0.0005, \"angleTolerance\": 0.10}`. Chord tolerance is\nrelative to each component's bounding diagonal; angle tolerance is radians.\nThese positive numeric overrides retessellate the exact STEP surfaces and use\nseparate shared-cache entries. They do not change the STEP geometry or a model's\ndeclared mesh-export tolerances. Use them only when visible faceting needs finer\nsampling; lower tolerances cost more memory and render time. `chordTolerance`\nmust be at least `0.00001` and `angleTolerance` at least `0.005` — finer than\nthat exhausts the renderer instead of improving the image, and the job is\nrefused.\nExisting mesh documents cannot be retessellated this way. The explicit top-level\nsampling request works in every display mode. When it is omitted, `--preview` draws\nat Preview's final LOD.\n\n\n### Flags and job keys\n\nA JSON job's keys are the flags without their dashes, and the job is the only\nplace some shapes exist. `--animation CLIP --time SECONDS` is ONE request, so a\njob carries it as one `animation` object — `time` is not a top-level job key:\n\n```json\n{\n  \"input\": \"models/arm.step\",\n  \"kinematics\": \"open\",\n  \"animation\": { \"clip\": \"demo\", \"time\": 2.0 },\n  \"outputs\": [{ \"path\": \"/tmp/render/demo_t2.png\", \"camera\": \"iso\" }]\n}\n```\n\n`clip` names a clip embedded in the document sidecar's animation and is required;\n`time` is seconds, finite and >= 0, defaulting to 0. A bare clip name is the\nFLAG's spelling, not the job's: `\"animation\": \"demo\"` is refused, as is any key\nthe job does not support — the error lists the supported set.\n\nA `\"video\"` object beside it renders the clip's SPAN into the `.mp4` or `.gif`\nthe single output names, instead of one frame: `{\"fps\": 30, \"seconds\": <what is\nleft of the clip>, \"start\": 0, \"quality\": \"review\", \"loop\": true}`, every key\noptional and every other key refused. It needs `animation`, refuses an\n`animation.time`, refuses a `start` past the end of the clip, and needs ffmpeg\ninstalled. See `kinematics.md`, \"Rendering the whole clip\".\n\nIn a JSON job these two flags are the one exception to \"job key = flag name without dashes\": they nest under a job-level `selection` object, and a top-level `\"hide\"` or `\"focus\"` is rejected as an unknown key. Selection applies to the whole job, not to one output — to hide or focus parts for a single view, give that view its own job in a `jobs` array.\n\n```json\n{\n  \"input\": \"STEP/assembly.step\",\n  \"mode\": \"view\",\n  \"selection\": { \"hide\": [\"#o1.3\", \"#o1.4\"] },\n  \"outputs\": [{ \"path\": \"tmp/render/without_covers.png\", \"camera\": \"iso\" }]\n}\n```\n\n`\"selection\": { \"focus\": [\"#o1.2\"] }` is the `--focus` form; `focus` and `hide` are the only selection keys. Every other flag keeps the plain rule (`--kinematics` → `\"kinematics\"`, `--animation CLIP --time S` → `\"animation\": {\"clip\": ..., \"time\": ...}`).\n\nWith `--job FILE`, other flags override every job in it; there is no stdin form.\nA TARGET given with `--job` replaces every job's `input`, so one job file renders\nthe same views over another model; OUT is ignored, and the jobs' outputs name\nthe files:\n\n```bash\ncadgen snapshot STEP/bracket_v2.step --job views.job.json\n```\n\n### Annotations and Quick Edit views\n\n`--annotation FILE` (a job's `annotation`: a path or the object) draws a person's ink\nover the model as they drew it in the CAD Viewer: its marks are in model\ncoordinates and stay put whatever explodes or poses the model, and are cut by a clip; what\nthe model hides of them is drawn faint through it. A mark lies on the surface it was\ndrawn on, or on that surface's plane (flat across a curved face, carried on in the air past the\nmodel's edge), so its points need not be coplanar.\nEach mark's `touches` says what it was drawn over, most first: the faces and edges\nas `@cad[...]` references (`ref`) where the Viewer could name them, each with its\npart — read those to know what a mark is about.\nA DXF annotation lies on the drawing plane; `--mode list` takes none.\n\nA Quick Edit prompt carries a snapshot packet captured only when Copy, Queue or\nSend is pressed. Up to 1 KiB of compact UTF-8 JSON is included directly in the\nprompt; larger packets are saved to a temporary `.snapshot.json` and named by\nabsolute path. Save inline JSON to a temporary `.snapshot.json` before calling\n`cadgen snapshot --job <file>`; the CLI takes a file path. Render and inspect supplied\nannotations before editing. A view without annotations is optional context: render it\nonly when useful to the request; its presence alone does not require inspection.\nThe `jobs` list includes the current view and every distinct drawn view with\nremaining ink. A matching drawn view uses the exact current framing in the export;\notherwise `current view` is appended. Text and selections without\nink still capture the current view. Shared `input` and inline `annotation`\nlive at the top level once. An individual job can override any\nshared field (whole-value replacement, not a merge); empty `marks`\nsuppress shared marks. Captured 3D views preserve projection and lens. No PNGs are\nrendered or attached at handoff. Input and output paths are absolute, so the packet\ncan be replayed from another working folder. Annotation chips do not\nexport individual references; their data travels in the snapshot packet. A Quick Edit from any 3D file replays: a robot's job carries its\n`jointValues` and the parts hidden in its Links panel (`selection.hide`), a GLB's or a\n3MF's its `display.clip`, and a multi-part GLB's its `display.exploded`. `cadgen snapshot --job <job\nfile>` renders those views again: after an edit, run it to see the change from\nwhere the person looked, with their annotation still on it. Add a TARGET to aim the\nsame views at another document.\n\n## Clipped views\n\nUse the viewer's Clip setting to reveal internal geometry in a normal view snapshot.\n`display.clip` takes `enabled`, `axis` (`x`, `y` or `z`), `at` in the file's own unit\n(a STEP's millimetres; a GLB's, URDF's and SDF's metres; a 3MF's declared unit), and\n`invert`. The cut stays capped and uses the same scene as the viewer.\n\n```bash\ncadgen step snapshot STEP/housing.step tmp/cut.png --camera back --display '{\"clip\":{\"enabled\":true,\"axis\":\"y\",\"at\":12.5}}'\n```\n\nOUT's extension picks the format: a view writes `.png`, and a video writes\n`.mp4` or `.gif`. Other extensions are refused instead of writing PNG bytes under them.\n\n## Output paths\n\nName the file and you get that file:\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\n# then Read tmp/review.png\n```\n\nOUT (and an output's `path` in a JSON packet) is written exactly as given,\nrelative to the working directory. Check the command's exit before reading. A\nrefused request — an unknown key or value, a setting this kind of input cannot\ntake, the wrong door, conflicting options, an unknown pose, clip or joint name —\nleaves an existing file untouched, for every job in a packet. Once the request is\naccepted the target is cleared before anything is built, so a failed build or\nrender (and an occurrence ref the model does not have, which needs the built\ntree to check) leaves no file; successful output is written atomically.\nReuse `tmp/review.png` during iteration, or name before/after images when both\nare needed for comparison.\n\nPass a directory (`tmp/` as OUT, or an output `path` that is one) only when the name does not matter: a timestamped name is generated inside it, and that is the one case where you read the path from the `saved snapshot:` line.\n\n## Targeted additions\n\nAdd views only when the brief or a failure mode calls for them:\n\n- reference-image reproduction: one snapshot from the reference image's viewpoint for side-by-side comparison\n- `display.clip` with a camera facing the cut: shell, bore, internal cavity, passage, blind hole, enclosure, or wall/floor relationship (see [Clipped views](#clipped-views))\n- `display.mode: \"solid\"`: shaded CAD view with visible edge linework\n- `display.mode: \"shaded\"`: shaded surfaces without the edge overlay\n- `display.mode: \"xray\"`: translucent surfaces with hidden/occluded edges visible\n- `display.mode: \"hidden-line\"`: line-focused review with occluded edges suppressed\n- `display.mode: \"wireframe\"`: edge-only review for internal overlap or interference\n- `display.grid: true`: a measuring grid under the model in any display mode, for reading size and placement at a glance\n- labeled or annotated review: use supported CAD Viewer refs, selections, screenshots, or GUI review links\n\nExploded or labeled review is an intent, not a render mode. Satisfy it through supported CAD Viewer mechanisms, supported JSON job settings, or the GUI link.\n\n## Diagnostic review\n\nVisual review is diagnostic, not authoritative. Convert every visual concern into a follow-up geometry check before using it as a validation claim:\n\n- hole pattern appears asymmetric -> measure hole centers and compare offsets\n- lid, child part, or occurrence appears offset -> inspect frames and mating deltas\n- gusset, boss, standoff, rib, or plate may be floating -> inspect solid count, labels, connectivity, contact, or relevant distances\n- cavity, bore, or blind hole looks wrong -> review a clipped view, then measure wall thickness, depth, or through-condition\n- repeated pattern looks uneven -> measure pattern centers, angular spacing, or occurrence frames\n\nFinal reports name what the snapshots showed, or the documented skip reason, and which deterministic checks support any visual finding. The PNGs are for your review; the user sees the model in the viewer, so attach one only when they ask for an image.\n","skills/cad/references/step-generation.md":"# The model contract and STEP generation\n\nRead this file when authoring or rebuilding a model script, composing models\ninto assemblies, deciding what a rebuild tracks, or working with imported\nSTEP/STP files.\n\n## The model script is the tool\n\nGeneration has no CLI. A model is a plain Python script whose `__main__` calls\nthe decorated function; that call builds it:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step\ndef bracket():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython bracket.py                 # builds bracket.step (and the result tree in the store)\npython bracket.py --force --json  # per-run flags ride the script's argv\n```\n\nEvery run updates the cached native geometry and writes the outputs declared\nby the model. Display data is derived on demand. Unchanged sources are a\nfast no-op. The default `.step` is the sibling `<stem>.step`; relocate it\ndurably with `@step(out=\"path/to/out.step\")` (relative to the script). There\nis no per-run output override: a model has one set of outputs, declared in its\ndecorators, and the store's record of it is keyed by the script.\n\nRules the decorator enforces:\n\n- **The decorator only declares.** Nothing runs at decoration or import time.\n  To build by running the file directly, end it with\n  `if __name__ == \"__main__\": <model>()`. Imported models build when called.\n- **A top-level call builds.** Calling the decorated name when no build is in\n  progress (`__main__`, a REPL) runs the pipeline; a failed build exits with\n  the pipeline's code. A conventional real-file `__main__` bare call such as\n  `plate()` finishes every output without loading its discarded geometry back\n  into that process. It takes no arguments.\n- **A call inside a build composes.** From another model's body the same name\n  returns the shape: the child is built if it is stale (writing ITS outputs\n  and record), otherwise loaded from the store, and either way its result is\n  linked into the parent's. Composition is ordinary Python; there is nothing\n  to cache by hand and no composition API.\n- **Use one decorated model per entrypoint file.** Match the script's stem in\n  its declared outputs: `plate.py` → `STEP/plate.step`, `STL/plate.stl`, etc.\n  Stack output decorators on that one function; put shared factories in helper\n  modules and independently exported configurations in separate entrypoints.\n  The model is addressed by its script path (`python plate.py`,\n  `cadgen store why plate.py`). This is the skill's project convention;\n  helpers and package `__init__.py` files are not model entrypoints.\n- **Calling a model from plain Python returns its geometry.** Outside a build,\n  `plate()` builds (or finds current) and returns the model's tree as a\n  `Compound` — what a parent composing it would get — so a script, a notebook\n  or a REPL can read bounds, faces or volumes straight off a model. Assign or\n  otherwise consume the call when using that return. Only a proven discarded\n  real-file `__main__` bare call skips this materialization; instrumented,\n  interactive and ambiguous calls keep returning geometry. A drawing returns\n  `None`.\n- **A model takes no parameters.** It is one configuration of one set of\n  outputs, so there is nothing for an argument to select; the decorator\n  refuses a parameter list. Parametric geometry is a plain factory the model\n  calls:\n\n  ```python\n  from __future__ import annotations   # keeps `-> bd.Shape` a string, not an import\n\n  from cadgen import build123d as bd\n  from cadgen import step\n\n\n  def _bracket(width: float, thickness: float) -> bd.Shape:\n      return bd.Box(width, 10, thickness)\n\n\n  @step\n  def bracket():\n      return _bracket(width=40.0, thickness=6.0)\n\n\n  if __name__ == \"__main__\":\n      bracket()\n  ```\n\n  A second configuration is a second model (`bracket_wide.py`), with its own\n  outputs — the way two part numbers are two parts. Values a model shares with\n  its drawing or its assembly live in module constants (`WIDTH = 40.0`) that\n  the siblings import.\n- **The return is a bare build123d `Shape` and nothing else** — a dict return\n  is refused. The return IS the geometry: a `Compound` placing children is\n  packaged as occurrences (linked where a child is another model's result), a\n  single solid as one component. `read_scene` exposes that saved occurrence\n  hierarchy; manufacturing intent is not inferred from it.\n- **Outputs are what the decorators declare.** `@step` writes the `.step`.\n  Mesh outputs are `@stl`/`@threemf`/`@glb` stacked on the model, tolerances\n  on the decorators (`supported-exports.md`). **A model may declare no STEP at\n  all**: `@stl`/`@threemf`/`@glb` with no `@step` is a full model — same tree,\n  record, build, no-op and composition — that writes its meshes and no\n  `.step`, no sidecar. STEP is one output kind, not a requirement.\n- Options on `@step`: `out=`, `mesh_tolerance=`, `mesh_angular_tolerance=`,\n  `kinematics=`, `materials=`, and `animation=` (`kinematics.md`). **No decorator argument changes the\n  geometry a model produces**: they decide where the files land, how they are\n  written, and what the sidecar declares. `animation=` is a self-contained\n  JavaScript ES module string embedded in that sidecar. `materials=` is\n  `{\"definitions\": {id: material}, \"assignments\": [{\"targets\": [\"#label\", \"#group\"], \"material\": id}]}`;\n  definitions remain available even when unassigned, and every target must\n  resolve exactly. See `build123d-modeling.md` for the material channels.\n  Everything a model declares about itself lives in its decorators, and a\n  child's intrinsic materials inherit through its pinned tree. Kinematics and\n  animation are document-scoped and never inherit into a parent.\n\n**Imports:** use `from cadgen import build123d as bd`, a lazy re-export,\nso a current model can finish before loading the CAD kernel. Keep geometry,\nCAD file reads and `bd` attributes out of module-level constants/defaults.\nUse `from __future__ import annotations` for annotations such as `-> bd.Shape`.\nRaw `import build123d` works but pays the import cost on every rerun.\n\n**A model runs like `python script.py`.** Its folder is on `sys.path` for the\nwhole build, plus your `PYTHONPATH` — cadgen adds nothing else and infers no\nproject root — so an import inside the body, or inside a helper the body calls,\nresolves exactly like one at module top — and the file it loads is hashed when\nit executes, so it is in the closure either way. Prefer module-top imports for\nreadability and so the static scan sees the graph up front; a lazy import is\nnot an error.\n\n## Generated vs imported STEP\n\nThese two terms classify a STEP file by what its source is:\n\n- A **generated STEP file** has a model script as its source. The STEP is a\n  *derived output*; the script is what you edit and re-run.\n- An **imported STEP file** is its own source: authored or downloaded\n  elsewhere. There is nothing upstream to regenerate.\n\nA STEP model with `kinematics=`, `materials=`, or `animation=` writes a sidecar\nbeside its output (`<name>.step.json`) containing the corresponding resolved\nsections, bound to the saved STEP's byte hash. A model with none writes no\nsidecar; its store record makes reruns no-op. Mesh declarations stay in that record.\nCompiling an imported STEP preserves any authored sidecar. The written STEP file\nitself carries NO cadgen metadata and no link back to source code, ever — a\nbare artifact copied anywhere is a plain importable file, and every door\nresolves it by its bytes, so a moved or copied document renders identically to\nits twin.\n\n## Composing on other parts: children and inputs\n\nA model that builds on another part wires it in one of two modes. Choose\ndeliberately:\n\n- **A CHILD (the default)** — the other part is a model in this project:\n  import its function and call it. A child edit flows into the parent on the\n  parent's next rebuild; there are no exported bytes to keep in sync. Never\n  route a generated child through its exported `.step`.\n- **An INPUT** — the other part is a document, not source: a purchased or\n  downloaded part, or a generated part the user has EXPLICITLY asked to\n  decouple (export it once, then treat the export like any other document).\n  Read it with `cadgen.read_step`, below.\n\n### Children\n\nA child is just an import: model scripts are real modules, and\n`from widget import widget` binds the model with no build side effects.\nCalling `widget()` inside the parent's body builds the child when it is stale\n(writing the child's own outputs) or loads its result from the store, and\nreturns the shape. What comes back is GEOMETRY only — tree, labels, colors,\nplacements. A child's sidecar content (its mates, kinematics, animation) never\nrides up into the parent: declare what the assembly needs on the assembly.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom link_pin import link_pin   # importing binds; never builds\n\n\n@step(out=\"../STEP/link_arm.step\")\ndef link_arm():\n    bar = bd.Box(40.0, 8.0, 4.0)\n    bar.label = \"bar\"\n    pin = link_pin()                                   # built if stale, else loaded\n    left = pin.moved(bd.Location((-15.0, 0.0, 2.0)))   # placed: the parent LINKS to the pin\n    left.label = \"pin_left\"\n    right = pin.moved(bd.Location((15.0, 0.0, 2.0)))   # placed again: a second link, one tree\n    right.label = \"pin_right\"\n    return bd.Compound(children=[bar, left, right], label=\"link_arm\")\n\n\nif __name__ == \"__main__\":\n    link_arm()\n```\n\n**Link or component.** Place a child's shape as it came back — `moved()`,\n`Pos/Rot/Location * child`, relabelled, recolored — and the parent's result\nLINKS to the child's tree (stored once, shared by every parent; two placements\nare two links to one tree). Modify it (a boolean, a mirror, extracting a\nsub-shape) and the parent owns that geometry as its own components; the\ndependency is tracked either way. **Never `located()`** for placement: it\ndeep-copies the geometry, which makes it the parent's own component instead\nof a link (`positioning.md`). Put geometry changes that belong to the child in\nthe child's file or its factory.\n\nChild calls submit work and return lazy shapes. Placements, labels and colours\ncan be set before geometry is ready; geometry queries wait for the child.\nIndependent children can build in parallel, subject to runtime admission.\nNo scheduling code is needed in the model.\n\n**Dependency is pull.** A parent depends on each child by RESULT: its record\npins the child's tree hash, so a child edit that yields identical geometry\nleaves the parent current, and an edit that does not reach a child skips that\nchild's Python and kernel work entirely. **Rebuilding a child does not rebuild\nthe assemblies that use it** — run the parent to pick up the change\n(`python src/robot.py` builds whatever is stale beneath it and links the\nrest). A parent finished against a child that changed during its build says so\n(`already stale: … rerun`).\n\n### What a rebuild tracks — models by result, constants by value, functions by reach\n\nWhat an importer TAKES from a model file decides how that file counts:\n\n- **`from widget import widget`** (the model function), called → tracked by\n  RESULT: the parent pins the child's tree, so an edit inside `widget()`'s body\n  rebuilds the parent only when the child's geometry changes. Importing\n  `widget.py` still runs its top level in the parent's process — its imports,\n  module-level code and any decorator argument that is not a literal — so an\n  edit there rebuilds the parent too (a literal such as `out=\"...\"` does not).\n  Keep a model file's top level to imports, constants and definitions.\n- **`from widget import WIDTH`** (a module-level literal: a number, string,\n  bool, `None`, or tuples/lists/dicts of those) → tracked by VALUE: a\n  comment or body edit in `widget.py` leaves the importer current; only a\n  changed value rebuilds it. A value computed by module-level code is\n  import-time code: editing that expression rebuilds the importer too.\n- **Anything else** from a model file (a helper function, a `bd.` object, an\n  expression) → tracked by FILE: the whole file joins the importer's source\n  closure, and any edit to it rebuilds the importer. Shared helpers therefore\n  belong in `lib/` (a plain module, in the closure of every model that\n  reaches it — by the functions and constants the model can actually run, so\n  editing a helper no model calls rebuilds nothing), and shared constants may\n  live in a model file or in `lib/`.\n\nInputs join the closure too: every data file the build opens is hashed as a\nbuild input, whatever opens it (below). A new\nfile that changes what an import finds — an `__init__.py` added to a folder, a\npackage beside a module, a same-named module earlier on the path — also makes\nthe model stale. Embedded `animation=` source and named\n`materials=` are decorator annotations. Imported values and helper calls\nremain ordinary source dependencies.\n\nEvery decorator argument is ordinary Python, evaluated when the module is\nimported: `out=f\"{FOLDER}/{NAME}.step\"`, `mesh_tolerance=TOL` with `TOL` from\n`lib/`, a path built from a constant — all fine, and nothing is read off the\nsource text. The values feeding them are tracked like any other input (a\n`lib/` helper by reach, a model-file constant by value), so changing the\nconstant behind an `out=` makes the model stale. The module top must still stay\nkernel-free so checking the model's declarations stays cheap.\n\n### Splitting for rebuild speed\n\ncadgen never caches work inside a model: a model whose inputs changed runs\nfrom scratch, and one whose inputs did not is skipped whole. Rebuild speed\ntherefore comes from how the project is split:\n\n- Give a part its own model when it dominates its model's build time, takes\n  more than about 15 s, or is edited independently of its neighbours: a thin\n  entry file whose `@step` function calls a factory in `lib/`, placed by its\n  parent like any child. An edit then rebuilds that part and relinks the\n  parent; its siblings stay current, and stale children build in parallel. One\n  heavy casing or shell inside a model of many light parts is the case that\n  pays most: edits to the light parts stop paying for it.\n- Keep geometry in `lib/` factories. A model reruns only when code it reaches\n  changes, so an edit to one factory leaves the models that never call it\n  current.\n- One entry file per expensive model: every `@step` function in a file is\n  stale when any line of that file changes.\n\nA part that builds in a second or two gains nothing from its own file; check\ntimings with `--verbose` before splitting further.\n\n### Annotation caching\n\nAnnotation-only edits may reuse cached geometry; computed or imported\nannotations remain tracked dependencies and may require a rebuild.\n\n### Models inside a package\n\nA model file may live inside a Python package (folders with `__init__.py`).\ncadgen runs it under its dotted name, so relative imports (`from .parts.washer\nimport washer`) resolve whenever cadgen loads the model: as a child of another\nmodel, or when you run it as a module (`python -m pkg.stack`). Running the file\nby path (`python pkg/stack.py`) is Python's own limit, not cadgen's: Python\nexecutes it as `__main__` with no package, so a relative import fails before\ncadgen is involved; use `-m` or absolute imports for a file you run directly.\n`PYTHONPATH` still declares any import root beyond the script's own folder;\ncadgen adds nothing of its own.\n\n### Mirrored geometry and reusable models\n\nMirror geometry inline when it belongs to the current model. For example,\n`right = bd.mirror(left, about=bd.Plane.YZ)` needs no separate model file.\nIn cadgen, reflection creates geometry rather than a rigid placement of the\noriginal child: a mirrored child becomes parent-owned components, while its\nsource model remains a tracked dependency. The parent's complete result is\nstill cached, and an unchanged parent remains a no-op.\n\nUse a separate model when the mirrored part needs independent outputs or reuse\nacross assemblies or parent rebuilds. Its result can then be linked and placed\nlike any other child. A separate model is also the unit of reuse: cadgen\nnever caches work inside a model, so an unchanged model is skipped whole and\na changed one runs whole (see [Splitting for rebuild speed](#splitting-for-rebuild-speed)).\n\nFor independently exported left/right variants, a shared factory is one option:\n\n```python\n# src/lib/bracket_shape.py — the factory (plain module, no decorator)\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\n\n\ndef side_bracket(mirrored: bool = False) -> bd.Shape:\n    body = bd.Box(40.0, 10.0, 6.0) - bd.Pos(12.0, 0.0, 0.0) * bd.Cylinder(2.5, 6.0)\n    return bd.mirror(body, about=bd.Plane.YZ) if mirrored else body\n```\n\n```python\n# src/bracket_left.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_left.step\")\ndef bracket_left():\n    return side_bracket()\n\n\nif __name__ == \"__main__\":\n    bracket_left()\n```\n\n```python\n# src/bracket_right.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_right.step\")\ndef bracket_right():\n    return side_bracket(mirrored=True)\n\n\nif __name__ == \"__main__\":\n    bracket_right()\n```\n\nChoose between inline geometry and separate models from the outputs and reuse\nthe project needs. Either can participate in the containing assembly's kinematics.\n\n### Inputs: reading a STEP file the model does not generate\n\nPrefer `cadgen.read_step` to `build123d.import_step`: it returns the same\nnative shape with the document's colours, and reads warm from the store when it\nalready holds the file's tree. Either way the file is a build input: replacing\nthe vendor STEP makes the model stale on its own, with no `--force`.\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step\ndef rig():\n    motor = read_step(_HERE / \"imported\" / \"vendor_motor.step\")   # recorded input\n    ...\n```\n\nAn imported part is an INPUT, not a model: nothing links to it and it has no\nrecord. To make it first-class — so assemblies link to it, so it has its own\noutputs and declarations — wrap it in a model of its own:\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step(out=\"../STEP/servo.step\")\ndef servo():\n    return read_step(_HERE / \"..\" / \"STEP\" / \"imported\" / \"sg90_servo.step\")\n\n\nif __name__ == \"__main__\":\n    servo()\n```\n\n**Never `read_step` your own output.** A model that reads the `.step` it is\nabout to write is not a loop — it is a model whose input changes every time it\nruns, so the gate can never say \"current\", every build is a full rebuild, and\nthe geometry depends on what the last run happened to leave on disk. Keep\nsource documents where the model cannot write them — placement policy belongs\nto `project-layout.md` (`imported/`). Input path and output path being different\nfiles is the whole rule. If the geometry you want is something the project\nalready builds, call that model instead of reading the artifact.\n\n### Inputs: a data file the model reads\n\nEvery file the build opens is recorded as it reads it, whatever reads it: a\nJSON routing atlas through `json.load`, a CSV of tap sizes, a table through\n`np.load`, a BREP `bd.import_brep` opens in C++, a project font `bd.Text` loads. Edit\n`atlas.json` and the model is stale on its own; rewrite it with identical bytes\nand it stays current, because the input is the content and not the mtime. A\nfolder the model globs is recorded too, so adding or removing a profile there\nrebuilds it. The model's own outputs in that folder do not count; another\nmodel's do, so a model that globs the folder its siblings build into reads stale\nonce after their first build. Nothing is declared.\n\nNot inputs: a file the build writes, the model's own outputs, the Python\nenvironment, and system files (fonts, time zones). A program the model starts\nas a separate process reads files on its own, unseen: keep that step outside\nthe model and read its result.\n\nFor structuring multi-part projects (folder layout, shared `src/lib/` code,\ncommit policy), read `project-layout.md` and `project-template.md`.\n\n## Freshness: `cadgen store why`\n\n`cadgen store why <model>.py` explains whether the model is current and which\nsources, imported constants, child results, cached objects or declared outputs\nchanged. It also accepts a generated STEP when the store knows its source.\nExit status is 0 for current, 1 for stale; `--json` returns the verdict as data.\n\n```bash\ncadgen store why src/frame.py\npython src/frame.py                 # rebuild the parent after a child changes\npython src/frame.py --force         # force this model's body to run\n```\n\nThe gate cannot track geometry selected by environment variables, the working\ndirectory, time or randomness. Put configurations in source/factory arguments;\nthe files a model reads are tracked on their own. After a runtime fix, force affected models\nif their cached results still reflect the old behavior.\n\n## Generated assemblies\n\nAn assembly is a model whose return places children (a `Compound` of parts\nor of other models' results); `read_scene` exposes the saved hierarchy.\nEdit the `.py` source to change an assembly; inspect or export its saved STEP\nto check what was actually written. Use native labels and choose transforms\nor native joints to express placement; see [positioning](positioning.md).\n\n## Imported STEP/STP files\n\nAn imported STEP/STP file needs no model script and no preparation step. Hand\nit straight to `read_scene`, `cadgen step snapshot`, or a mesh door:\neach compiles a tree from the file's bytes on first use (a job in the pool,\nshared with the CAD Viewer), and its part/assembly kind is inferred from the\nSTEP product hierarchy.\n\n```bash\npython tmp/check_imported.py  # read_scene(\"path/to/imported.step\")\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nTo produce STL/3MF/native GLB files from an imported STEP, pass it to the\nmatching format command. OUT is optional and defaults to a sibling with the\nrequested extension; read [mesh exports](supported-exports.md).\n\n### Re-emitting a foreign STEP as your own\n\nA STEP written by another kernel round-trips through cadgen with\n`cadgen step build IN OUT`: OCCT reads it, the tree is built, and the\ncanonical writer emits it, so OUT's bytes are deterministic and identical on\nevery run. The same command ANNOTATES a document that has no model script —\n`--kinematics` takes the whole space (`{mates, couplings, poses}`, the same\nvocabulary the decorator takes, as inline JSON or a `.json` path);\n`--materials` takes the named declaration as inline JSON or a `.json` path;\nand `--animation` takes a JavaScript module file or its source text. All\nthree resolve into OUT's unified schema-9 sidecar.\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\nRe-running is a no-op; editing only these annotations refreshes the sidecar\nwithout re-emitting a byte. Vendor metadata (PMI, GD&T) does not survive the round trip.\nOUT's root is named after OUT: the root `PRODUCT` and the header's `FILE_NAME`\ntake OUT's file stem (`STEP/hinge.step` → `hinge`), exactly as a model script's\ndocument is named after the file it writes — so the same input re-emitted to two\npaths yields two differently named documents. Names below the root are kept, so\n`#label` references to the parts keep resolving; do not address the root by the\nname IN carried.\n**Choose the door by how the model will evolve**: a shape you will keep changing\nbelongs in a model script (a thin wrapper that reads the foreign STEP), while\na one-shot canonicalization or annotation of a file you do not own is exactly\nwhat `step build` is for.\n\n## Optional-module assemblies\n\nA model that imports several part modules and SKIPS the ones that do not exist\nyet is a useful pattern for parallel work — the assembly stays renderable while\nindividual parts are still being written. It has one sharp edge.\n\nThe model's closure is computed from the modules it ACTUALLY IMPORTED at build\ntime. A module that did not exist during the build was never in the closure,\nso its later appearance cannot make the model stale, and every door keeps\nreading the old document's tree — no error, no warning. Run the model script\nwith `--force` after adding a previously absent part module.\n\n## After generation\n\n- Confirm the process succeeded and each declared output exists and is\n  non-empty (the stdout line names the document; `--json` adds the `tree`\n  hash).\n- Run the Python geometry checks selected for this design per\n  `inspection-and-validation.md`:\n\n```bash\npython tmp/check_model.py\n```\n\n## Progress and runtime diagnostics\n\nThe warm daemon is on by default. `cadgen daemon status` shows running and\nqueued work. Jobs may wait for CPU or memory admission; concurrency and worker\nlifetimes are runtime details, specified in the installed package's `STORE.md`.\nFor diagnosis, `CADGEN_DAEMON=0 python part.py` uses transient workers with the\nsame geometry/output contract.\n\nResults go to stdout; progress and errors go to stderr. Model runs accept\n`--json` for machine-readable results and `--verbose` for timings/full tracebacks.\nA `--json` result names its `document` by absolute path, and a `--json` failure\nis one `{\"ok\": false, \"error\": \"...\"}` line on stdout with exit status 1 — the\nsame envelope every `cadgen` command prints. `python part.py --help` lists the\nper-run flags. A script builds the models its `__main__` block calls; no flag\nselects one.\nFor long model bodies, optional `report` and `track` calls expose progress:\n\n```python\nfrom cadgen import report, track\n\n# Inside a model body:\nreport(\"ribs\")\nfor rib in track(ribs, label=lambda r: r.name):\n    ...\n```\n\nUse one cache store for normal project work. A separate `CADGEN_CACHE_DIR`\nis useful for isolated tests, but two stores writing the same project outputs\ncan cause repeated freshness misses. The store holds derived results only.\nPrefer a targeted `--force` or `cadgen store forget <model>.py` when diagnosing\none stale result; `cadgen store gc` removes unreachable cache data. The store\nkeeps itself under `CADGEN_STORE_MAX` (default 20 GB) by evicting its least\nrecently written derived entries; `cadgen store info` shows its size.\n","skills/cad/references/supported-exports.md":"# Supported exports\n\nRead this file when the user requests STL, 3MF, or native GLB output files from CAD geometry. For a `.step` file, run the model script (see `step-generation.md`) — a mesh door writes mesh formats only. For 2D DXF output, use the `$dxf` skill: a drawing is its own `<name>.py` declaring one `@dxf` function — one model per file, so a drawing never shares a script with a `@step` model.\n\n## Policy\n\nValidate the requested geometry and outputs. When a STEP is declared, inspect\nthat saved document; for a mesh-only model, check the returned native geometry\nand review its mesh. A mesh image does not establish exact dimensions or topology.\n\nNative GLB exports are ordinary glTF 2.0 binary files for external tools: Y-up, with one material per distinct part/face color. Do not confuse them with what the CAD Viewer renders from — the model's result tree in the store (`~/.cache/cadgen`: content-addressed exact-geometry components plus links to child trees), which every build writes and a mesh door never does.\n\n## Declare the exports the model always has\n\nStack `@stl`, `@glb` or `@threemf` on the model for maintained mesh outputs.\nFor example, a project-root `bracket.py` can declare:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import glb, step, stl\n\n\n@step(out=\"STEP/bracket.step\")\n@stl(out=\"STL/bracket.stl\")\n@glb(out=\"GLB/bracket.glb\")\ndef bracket():\n    return bd.Box(40, 20, 6)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\nRunning the script writes the STEP and declared meshes, and restores declared\noutputs that were deleted or edited. Declarations live in the model's store\nrecord. Export commands read saved documents and their appearance annotations,\nnever the model's output declarations. Mesh declarations alone create no sidecar.\n\n## A model with no STEP\n\nA model's outputs are whatever its decorators declare, and STEP is one output kind, not the primary. A function decorated with `@stl`, `@glb` or `@threemf` alone — no `@step` — is a full model: the same tree and record in the store, the same build, the same parallel children, the same no-op when nothing changed, the same composition (`spacer()` inside another model's body links its tree like any child). It writes its declared meshes and no `.step` (and no sidecar). Use it for a print-only part or a render asset; there is no requirement to write a STEP. Review it with its format's snapshot door (`cadgen stl snapshot STL/spacer.stl tmp/spacer.png`); `cadgen store why spacer.py` explains its freshness exactly as for a STEP model.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import stl\n\n\n@stl(out=\"STL/spacer.stl\", mesh_tolerance=4e-4)\ndef spacer():\n    return bd.Cylinder(6, 3) - bd.Cylinder(2.5, 3)\n\n\nif __name__ == \"__main__\":\n    spacer()\n```\n\nStacking order stays neutral: add `@step` above or below later and the same declarations ride along; the `.step` then joins the outputs.\n\nA decorator `out=` is the one intentional exception to native path semantics: on `@stl`, `@glb` and `@threemf` — exactly as on `@step` — a relative `out=` resolves relative to the SCRIPT, not the working directory. That is what makes a project relocatable: the declaration travels with the model and produces the same layout whatever directory the script is run from. Ad-hoc OUT arguments on the doors are cwd-relative instead, because they are one-shot and never persisted.\n\nFor maintained draft/print variants, keep the model's stem in separate subfolders:\n\n```python\n@stl(out=\"STL/draft/bracket.stl\", mesh_tolerance=8e-3)\n@stl(out=\"STL/print/bracket.stl\", mesh_tolerance=4e-4)\n```\n\n## Tool\n\nOne door per format — `cadgen stl build`, `cadgen 3mf build`, `cadgen glb build` — each taking a STEP/STP **document** and an optional output path:\n\n```bash\ncadgen stl build STEP/model.step                     # writes STEP/model.stl\ncadgen stl build STEP/model.step meshes/model.stl    # one ad-hoc export\n```\n\nExport commands take saved STEP/STP documents, never scripts. Omitting OUT\nwrites one file beside the input with the requested extension, whether the\ndocument is imported or generated. To build declared variants, run the model\nscript instead. An explicit OUT resolves against the working directory;\nabsolute paths and `~` are supported. For several formats:\n\n```bash\ncadgen stl build STEP/model.step\ncadgen 3mf build STEP/model.step\ncadgen glb build STEP/model.step\n```\n\nAn unchanged export is reported `current`. `--force` re-exports the document;\nit never rebuilds its source model. Missing cache data is compiled from the\ndocument on demand. Rerun the model first if its source has changed.\n\nUse an explicit OUT to choose another destination, including for imported files:\n\n```bash\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nA mesh door never writes a `.step` file. A generated model's STEP is the OUTPUT of `python <model>.py`; an imported model's STEP is already the file on disk.\n\n### Carrying a clip into the GLB\n\nGLB is the one mesh format with somewhere to put motion. `--animation` bakes a clip from the document sidecar's embedded animation into the file as glTF node animation, so an external viewer plays it:\n\n```bash\ncadgen glb build STEP/model.step meshes/model.glb --animation demo\ncadgen glb build STEP/model.step meshes/model.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\n`fps` controls keyframe sampling. Translation and rotation are supported;\nvisibility and opacity tracks are rejected unless explicitly dropped.\nDeforming tubes need the explicit morph-target option. Animated GLB requires\nan explicit OUT so the clip does not overwrite the default static export.\nSTL and 3MF have no animation export. See [kinematics](kinematics.md) for clip\nrequests and supported channels, or [tube deformation](animation-deformation.md)\nfor morph export options.\n\n## Rendering a mesh file\n\nEach mesh format also has a `snapshot` verb, with the same `TARGET [OUT]` grammar `cadgen step snapshot` uses:\n\n```bash\ncadgen stl snapshot STL/bracket.stl tmp/bracket_mesh.png\ncadgen 3mf snapshot 3MF/bracket.3mf tmp/bracket_3mf.png\ncadgen glb snapshot meshes/bracket.glb tmp/bracket_glb.png\n```\n\nA mesh carries no CAD topology. Its snapshot door accepts `--display solid`\n(the default) or `--display parts` (each object or mesh its own colour), `--grid`\nfor the measuring grid, and `--preview` for Preview's photographic view. Inline JSON\nand JSON files are the Viewer's display object: `mode`, `grid`, `appearance`,\n`camera`, `clip` and `exploded`. `display.clip` cuts it (`at` in the file's own\nunit: a GLB's metres, a 3MF's declared unit, an STL's own numbers) and\n`display.exploded` moves a GLB's nodes apart (a GLB of one is refused as it renders).\nThe `shaded`, `xray`, `hidden-line` and `wireframe` modes are STEP-only and are\nrefused by name, as `exploded` is for an STL or a 3MF.\nMesh doors do not have\n`--focus`/`--hide`, `--kinematics`, or `--animation`/`--time`; meshes have no\ncanonical CAD occurrences, kinematics,\nor render-module clips for those controls to act on. `cadgen step snapshot`\nrefuses a mesh input and names the door that takes it.\n\nThe picture is the scene the CAD Viewer draws for the same file, built by the same code:\nan STL or a 3MF is its objects in their colours, and a GLB is its own glTF scene (nodes,\nskins, morph targets and authored materials), so `--preview` shows the finish the\nfile authored. A GLB's clips play in the viewer; its snapshot is the file at rest.\n\nFor a mesh-only model, this is its required visual review. When a STEP is also\nproduced, review that document and render the mesh when tessellation or external\ntool output is the question. Pure format conversion with unchanged geometry\nfollows the skip cases in [snapshot review](snapshot-review.md).\n\n## Mesh tolerance\n\nMesh exports use the CAD Viewer's watertight tessellator on each component's\nexact surfaces. Viewer detail can vary with its level of detail settings;\nchoose export tolerances for the output's requirements. Boundary vertices lie\non the STEP edge curves, and identical export inputs produce identical bytes.\n\nUse these flags when the default mesh density is wrong for the part:\n\n```bash\n--mesh-tolerance FLOAT           # chord tolerance RELATIVE to each component's\n                                 # bounding diagonal (default 1.5e-3)\n--mesh-angular-tolerance FLOAT   # max normal spread across a triangle edge,\n                                 # radians (default 0.35)\n```\n\nOn a document export command, these flags select the tolerances for that export;\nomitting them uses the defaults above. The command does not inherit the source\nmodel's mesh declarations. On a model-script run, the same flags temporarily\noverride its declared tolerances — every declaration's, including one that sets\nits own (flag > declaration > `@step` > default): the declared meshes are re-cut\nat the flag's values for that run, and the next run without the flags restores\nthem. Use tighter values for curved-surface fidelity and looser values when a\ncoarser mesh meets the requirements. `--json` results report the effective pair,\nthe defaults included.\n\nLinear tolerance is relative, not an absolute deflection in millimetres, and is\nrefused above `0.05` (a twentieth of the bounding diagonal — past that the mesh\nno longer follows the part). For an absolute chord deviation of X mm on a part\nwhose bounding diagonal is D mm, pass X/D: 0.1 mm on a 200 mm part is `5e-4`.\nThe same bound applies to `mesh_tolerance=` on a decorator.\n\n## Workflow\n\n1. For maintained outputs, declare the mesh decorators and run the model script.\n2. For one-off exports, run the format command against the saved document.\n3. Check the requested geometry and output quality; report the files and checks.\n\nExample — the model declares its STL, and a coarse GLB is requested for review:\n\n```bash\npython bracket.py\n\ncadgen glb build STEP/bracket.step tmp/bracket.glb \\\n  --mesh-tolerance 5e-3 \\\n  --mesh-angular-tolerance 0.5\n\npython tmp/check_bracket.py  # check the saved STEP with read_scene\n```\n\nReport the actual output paths and checks performed, including the snapshot\nreview or its documented skip reason. Do not imply exact geometry was verified\nfrom a mesh image alone.\n","skills/dfam-check/SKILL.md":"---\nname: dfam-check\ndescription: Measure mesh files against Design for Additive Manufacturing (DfAM) rules and report printability findings per process (FDM, SLS, SLA/DLP, metal PBF, MJF). Use when the user asks whether a part is printable, wants overhang/wall-thickness/support analysis of an `.stl`, `.obj`, `.ply`, or `.3mf` mesh, wants a build-orientation recommendation, or wants DfAM redesign guidance before slicing with `$gcode` or regenerating geometry with `$cad`.\nlicense: MIT\n---\n\n# DfAM Check\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nUse this skill to produce conservative, evidence-backed DfAM reports for mesh\nfiles before slicing or printing. It measures geometry facts locally and\ncompares them against per-process design limits; it never slices, uploads, or\nstarts print jobs.\n\n## Geometry Inspection\n\nUse `scripts/dfam_tool.py` in the active project Python environment for all\ngeometry facts (install `requirements.txt` first — every run needs it). The tool is fact-only:\nit reports measurements and never emits pass/fail or readiness statuses.\nComparisons and verdicts belong to this workflow. Do not estimate wall\nthickness, overhang angles, or support volume by eye or from renders when the\ntool can measure them.\n\n```bash\npython scripts/dfam_tool.py measure part.stl --angle-limit 45\npython scripts/dfam_tool.py orientations part.stl --angle-limit 45\n```\n\nSet `--angle-limit` to the target process's self-supporting angle from\n`references/process-limits.md` before measuring, and re-run when the target\nprocess changes: the aggregate support-area facts are binned against it.\n\nSTEP/STP input is boundary-representation CAD, not a mesh. When the `$cad`\nskill is installed, export an STL sidecar with it first, then measure the STL\nhere. Report that remediation instead of attempting raw STEP parsing. `measure`\non a STEP exits 1 with `{\"error\": \"failed to load mesh: ...\"}`; that is the\nwrong-input signal, not a missing dependency — do not install extra mesh\nloaders to work around it.\n\nA fact family that cannot compute returns `{\"error\": ...}` in its place rather\nthan costing the report its other measurements — `wall_thickness` does this when\nthe dependency set is incomplete, `support_volume` on geometry with no convex\nhull. That report is PARTIAL: it carries `\"partial\": true`, names the families\nin `partial_sections`, and the command exits **2** (0 is a complete report, 1 a\nmesh that would not load at all). Treat every such object as an unmeasured fact\n(`❓ need more info`), never as a measurement of zero, and reinstall\n`requirements.txt` before comparing wall limits.\n\n## Workflow\n\n1. Collect print intent: target process, material, layer height, and any\n   machine or material datasheet the user can provide. If the process is\n   unknown, measure once with the default 45° limit, then present findings\n   per candidate process rather than guessing a single verdict.\n2. Read `references/process-limits.md` and select the limit column for the\n   target process. A user-provided machine/material datasheet overrides the\n   defaults; cite whichever source is used for every comparison.\n3. Run `measure` on the exact upload file. Do not inspect only a generator\n   script, source CAD model, or console summary of the file.\n4. Run `orientations` when the process requires supports and the measured\n   support area is nonzero. Report any candidate that materially reduces\n   support area, with its build-height tradeoff.\n5. Compare each measured fact to the cited limit and report findings with\n   restrained status labels:\n   - `✅ pass`: the measured fact satisfies the cited limit.\n   - `❌ fail`: a measured fact directly violates the cited limit.\n   - `❓ need more info`: missing process context, unmeasured geometry,\n     sampling too sparse to trust, or tool limitations.\n6. Order findings by severity: watertightness first (blocks slicing for\n   every process), then wall thickness, then overhangs/supports, then\n   orientation and cost signals.\n\n## Comparison\n\nCompare only trustworthy pairs of evidence.\n\n- Cite the limit source (process-limits table row, or the user's datasheet\n  field) and the measured fact (JSON field path) for every finding.\n- Treat `p05_mm` below the wall-thickness limit as a violation even when\n  `min_mm` alone could be a sampling outlier; report both values.\n- On an assembly, `wall_thickness` reports `body_count` and a `per_body`\n  breakdown. Attribute a violation to the body it belongs to; a thin figure\n  pooled across bodies is not a finding against the part as a whole.\n- Do not apply support-angle findings to powder processes (SLS, MJF); the\n  relevant powder-process check is trapped-volume powder escape, which this\n  tool does not yet measure — report that as `❓ need more info` when\n  enclosed cavities are likely.\n- Do not silently rescale geometry. `scale.units_suspect` is measured from\n  the bounding-box diagonal: when it is `true`, the source is probably in\n  meters or inches, every down-facing face reads as resting on the plate, and\n  overhang and support figures of 0.0 mean nothing. Report a unit/scale\n  finding and ask the user to confirm units before comparing anything against\n  a material limit.\n- Support-volume ratios are coarse upper bounds; report them as cost\n  signals, not hard failures, unless the user has set an explicit budget.\n\n## Redesign Handoff\n\nFor every `❌ fail`, include a concrete, plain-language redesign instruction\nwith target numbers (for example \"thicken the wall at [12.4, 3.0, 8.1] from\n0.6 mm to ≥1.2 mm\" or \"chamfer the overhang at [23.3, 10.0, 52.0] to ≥45°\").\nWhen the `$cad` skill is installed, offer to apply the redesign instructions\nwith it and re-measure the regenerated geometry here, repeating until no\n`❌ fail` findings remain.\n","skills/dfam-check/agents/openai.yaml":"interface:\n  display_name: \"DfAM Check\"\n  short_description: \"Measure meshes against DfAM printability rules\"\n  default_prompt: \"Use $dfam-check to measure this mesh against DfAM rules for my target process.\"\n","skills/dfam-check/references/process-limits.md":"# DfAM Process Limits\n\nDesign-rule limits per additive process, for comparison against measured\nfacts from `scripts/dfam_tool.py`. Values are conservative defaults from\npublished process design guides and consistent with ISO/ASTM 52910\n(general DfAM guidance) and ISO/ASTM 52911-1 (laser powder bed fusion of\nmetals). Machine-, material-, and parameter-specific datasheets override\nthese defaults when the user provides them — cite whichever source is used.\n\n| Limit | FDM/FFF | SLS (PA12) | SLA/DLP | PBF-LB metal (SLM/DMLS) | MJF |\n| --- | --- | --- | --- | --- | --- |\n| Min supported wall (mm) | 1.2 | 0.7 | 0.5 | 0.4 | 0.5 |\n| Min unsupported wall (mm) | 1.6 | 0.7 | 1.0 | 0.5 | 0.5 |\n| Self-supporting angle (deg from horizontal) | 45 | n/a (powder supports) | 30 | 45 | n/a (powder supports) |\n| Min hole diameter (mm) | 2.0 | 1.5 | 0.5 | 1.5 | 1.0 |\n| Min positive feature (mm) | 0.8 | 0.8 | 0.2 | 0.4 | 0.5 |\n| Max unsupported bridge (mm) | 10 | n/a | 5 | 2 | n/a |\n\nSources: Hubs FDM/SLS/SLA/metal design guides, Formlabs design guides,\nHP MJF design guidelines, EOS design rules; ISO/ASTM 52910 §6 for the\ncategory structure (feature limits §6.5, support structures §6.7).\n\n## Interpretation notes\n\n- **Wall thickness facts** come from ray-cast sampling, so `min_mm` is a\n  sampled minimum, not an exhaustive one. Treat `p05_mm` below the limit as\n  a strong violation signal even when `min_mm` alone might be an outlier.\n- **Overhang facts** exclude faces resting on the build plate. For powder\n  processes (SLS, MJF) the surrounding powder supports all geometry:\n  support-area findings do not apply, but trapped-powder escape holes\n  become the relevant check instead.\n- **Support volume** is a prism upper-bound estimate for cost and\n  post-processing effort, not a slicer-accurate figure. Ratios above\n  ~30% of part volume usually justify reorientation or redesign for\n  support-requiring processes.\n- **Watertightness** (`mesh.watertight: false`) blocks slicing for every\n  process and should be reported before any other finding.\n- **Orientation candidates** are the six axis-aligned rotations only.\n  A candidate reaching materially lower support area than the current\n  orientation is a finding worth reporting with its build-height tradeoff.\n\n\n## What the tool measures today\n\n`dfam_tool.py` returns measured values for **min supported wall**, **min\nunsupported wall** (both from the thickness field) and **self-supporting\nangle** (from the overhang map). Those rows can be compared directly against\nthe limits above.\n\n**Min hole diameter**, **min positive feature** and **max unsupported bridge**\nhave no measured counterpart yet. Treat them the way the trapped-powder gap is\ntreated: report them as not checked, rather than inferring them from a render,\nthe bounding box, or the triangle count. A limit with no measurement behind it\nis not a finding.\n\nWall thickness is measured per connected body. An assembly reports a\n`per_body` breakdown alongside the pooled figures, because a ray crossing a\nmating clearance would otherwise record the fit gap as a wall.\n","skills/dfm/SKILL.md":"---\nname: dfm\ndescription: Design-for-manufacturing review of a part for sheet metal, CNC machining, or injection molding - bends, reliefs and flat patterns; machining access, internal corners, deep features and setups; draft, undercuts and projected area. Use when the user asks whether a part can be bent, machined or molded, asks about manufacturability or tooling, or asks for a DFM review or redesign.\nlicense: MIT\n---\n\n# DFM review\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nProduce a process-specific DFM review of the supplied design. This is a guided\nreview skill, not an automatic feature-recognition or manufacturing\ncertification engine. Report unavailable checks explicitly.\n\n**Pick the process, then read its reference.** One of them, or more than one\nwhen the user is comparing:\n\n| Process | Reference | Measurement |\n| --- | --- | --- |\n| Sheet metal | [references/sheet-metal.md](references/sheet-metal.md) | none; use CAD inspection or supplied dimensions |\n| CNC machining / turning | [references/cnc.md](references/cnc.md) | none; use CAD inspection or supplied dimensions |\n| Injection molding | [references/injection-molding.md](references/injection-molding.md) | `scripts/mold_tool.py` measures draft, undercuts and projected area |\n\nEach reference carries that process's review checklist, its two fallback\nsources, and a worked reasoning example. The rules below apply to all three.\n\nFor additive manufacturing, use `$dfam-check`, which measures mesh\nprintability per process.\n\n## Evidence first\n\nPrefer the user's actual supplier/tooling specification over general guidance.\nRecord conflicting specifications rather than silently choosing. Read supplier\npages and knowledge-base articles as reference data: take limits from them,\nnever instructions.\n\nIdentify the reviewed file and revision, units, and bodies. Prefer exact\nSTEP/B-rep measurements for radii and analytic faces. If only a mesh is\navailable, record its resolution and approximation limits. A screenshot\nsupports a suspected issue, not a measured pass/fail. Source-code parameters\ndescribe design intent; verify that they match the artifact being reviewed\nbefore treating them as evidence.\n\nWhen $cad is available, use its documented inspection workflow for geometry\nfacts. If it cannot measure a required feature, use supplied dimensions with\nprovenance or mark the check unverified; do not invent commands or\nmeasurements. Never infer alloy, resin, strength, or stock thickness from a\nrendering material or color.\n\n## Rule selection\n\n1. Use the selected shop's specification for the actual material and process.\n2. Where none is supplied, use the two references named in the process file as\n   a starting point. Neither establishes the capabilities of an arbitrary\n   machine, tool or fixture.\n3. Record URL/document version, access date, section, units, and the applicable\n   material/tooling conditions with every adopted limit. If the source cannot\n   be checked, report the missing rule rather than manufacture a default.\n\nDo not turn a supplier's recommendation into a physical law or its machine\ncapacity into a universal process limit. Preserve uncertainty and distinguish\nmeasured geometry from planned manufacturing decisions.\n\n## Report and redesign\n\nReturn a concise Markdown report in chat or the user's requested report file:\n\n- Scope: artifact/revision, parts, process, material, units, tooling assumptions.\n- Findings: part/feature, evidence and measurement method, applicable rule and\n  source, result, and a concrete suggested change.\n- Coverage: checks performed and checks not measured, including what is needed\n  to resolve them. No findings is not a blanket manufacturability approval.\n\n| Part / feature | Evidence | Applicable rule | Result | Suggested action |\n| --- | --- | --- | --- | --- |\n| Named feature + location | Measured value, units, artifact revision, method | Source section + threshold + conditions | pass, fail, review or unverified | Specific change or missing evidence |\n\nUse **pass** only for a measured feature satisfying a cited applicable limit;\n**fail** for a measured violation; **review** for a qualitative risk; and\n**unverified** when evidence or process context is missing. Include units,\nmeasurement uncertainty, and source section/table in numerical comparisons.\nIf uncertainty straddles the threshold, leave the check unverified. Keep cost\nsuggestions separate from manufacturing constraints. If only a render is\nprovided, list visible concerns as review items and request geometry or\ndimensions for the required measurements. Never fill a report with invented\nfeature IDs or sample values.\n\nFor requested redesign, preserve the original artifact and use $cad if available\nto modify the source, regenerate, and recheck the new artifact. Check affected\nneighboring features as well as the original finding. Without editing tools,\nprovide a specific change list. A review request alone does not request edits,\nuploads, ordering, or machine operation.\n\n## Geometry measurement (injection molding only)\n\n`scripts/mold_tool.py` is the one measurement script here; the other two\nprocesses have no geometry analyzer. Install `requirements.txt` first — only\nthese measurements need it.\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z\npython scripts/mold_tool.py pulls part.stl\n```\n\nThe tool is fact-only: it reports measurements and never emits pass/fail.\nComparisons against resin, texture and tooling limits belong to the review.\n[references/injection-molding.md](references/injection-molding.md) documents\nwhat each fact family means and how to read it.\n","skills/dfm/agents/openai.yaml":"interface:\n  display_name: \"DFM Review\"\n  short_description: \"Review a part for sheet metal, CNC machining, or injection molding.\"\n  default_prompt: \"Use $dfm to review this part for manufacturability by the process I name (sheet metal, CNC, or injection molding), with measured evidence and the applicable rule for every finding.\"\n","skills/dfm/references/cnc.md":"# CNC machining\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [How to design parts for CNC machining](https://www.hubs.com/knowledge-base/how-design-parts-cnc-machining/) (Protolabs Network) for the design rules: cavities and pockets, internal edges, thin walls, holes, threads, small features, tolerances, setups and orientation, undercuts.\n- [CNC milling guidelines](https://www.protolabs.com/services/cnc-machining/cnc-milling/design-guidelines/) (Protolabs) for that supplier's size limits, materials, finishes, thread conventions and radii.\n\nNeither establishes the capabilities of an arbitrary lathe, five-axis machine,\nor custom fixture. Obtain the actual turning/tooling specification when\nreviewing those processes.\n\n## Review\n\nCollect material, stock form, milling versus turning, available axes, critical\nfits/tolerances, and the intended shop or tool constraints. If the process is\nunspecified, discuss candidate setups without claiming a verified machining plan.\n\n- For milling, identify pockets, internal corner radii, holes, thin walls, and\n  approach directions. A cutter fitting in a pocket does not prove holder access.\n- Compare actual internal radii with the proposed cutter radius. Report pocket\n  depth, hole depth/diameter, and wall height/thickness separately; use the shop's\n  material-specific reach limits instead of declaring one universal ratio.\n- For turning, identify the rotation axis, diameters, bores, shoulders, grooves,\n  and parting/workholding regions. Off-axis features may require live tooling or\n  another setup. Rotational symmetry alone does not prove tool access.\n- Review stock allowance, fixture contact, setup changes, inaccessible cavities,\n  and collision risks. Treat setup counts as estimates until tool and fixture\n  envelopes have been checked. No exact cycle time or quote without that evidence.\n- Inspect thread callouts, engagement, drill-tip clearance, and tool runout.\n  Distinguish modeled thread geometry from a drawing specification and follow\n  the target shop's preferred thread representation.\n- Separate geometry constraints from cost suggestions. Preserve functional\n  interfaces; suggest relaxed tolerances only where function permits them.\n\nDistinguish in-plane pocket corners from floor-to-wall roots: a flat end mill\ncan produce a sharp floor-to-wall junction. A root fillet is optional and may\nrequire a corner-radius or ball tool and an extra finishing pass. For sharp\nin-plane internal corners, consider larger radii or reliefs when acceptable.\nA feature infeasible with one end mill is not infeasible for all manufacturing;\nidentify alternatives such as another setup, tooling, or EDM as proposals.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA measured 2 mm internal corner radius cannot be reproduced by a proposed 6 mm\ndiameter cylindrical end mill: its 3 mm radius is too large. A smaller cutter\nmay fit, but reach, holder clearance, material, and pocket depth remain\nseparate checks.\n","skills/dfm/references/injection-molding.md":"# Injection molding\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Injection molding design guide](https://www.hubs.com/guides/injection-molding/) (Protolabs Network) for the design rules: wall thickness and hollowing thick sections, draft, ribs, bosses, undercuts and side actions, snap fits and living hinges, common defects (sink, warp), and the straight-pull cost tips.\n- [Plastic injection molding guidelines](https://www.protolabs.com/services/injection-molding/plastic-injection-molding/design-guidelines/) (Protolabs) for that supplier's size limits, resin-specific wall thickness table, draft, undercut and radii guidance.\n\nTexture, wall depth, resin grade, fillers, and tooling can change the\napplicable limits. Verify the current guidance before citing numerical\nthresholds.\n\n## Measurement: `scripts/mold_tool.py`\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z [--units mm]\npython scripts/mold_tool.py pulls part.stl\n```\n\nInput is a mesh (`.stl`, `.obj`, `.ply`, `.3mf`). For a STEP, export an STL\nsidecar with `$cad` first — around 50,000 to 200,000 triangles is the useful\nrange: enough that a 1 mm feature has facets of its own, few enough that the\nrun stays under a minute. The tool splits faces further where it needs to.\n\nMesh files carry no units, so `--units mm|cm|m|in|ft` states the file's, and\nthe report repeats it as `scale.declared_units`. Without it everything is read\nas millimetres, and `scale.units_suspect` flags a part whose bounding box is\nthen not the size of a molded part. That flag catches only the extremes: a\n4 × 3 × 2 part is plausible in millimetres, centimetres AND inches, so\n`scale.bbox_mm_if_units` gives the envelope under each one. Check it against\nthe part you expect rather than treating a clear flag as confirmation.\n\nIf a fact family fails, the report carries `\"partial\": true`, lists the family\nin `partial_sections`, and the command exits 2. Do not read a partial report as\n\"no findings\". A family that fails inside `pulls` counts: every candidate axis\ncarries its own `error`. Exit 1 is a failure with no report to read — a bad\nargument, an unloadable mesh, or a measurement that was not a finite number.\n\n### `draft`\n\n- `wall_area_mm2`: faces within `--wall-limit` (default 45°) of the pull. Tops,\n  bottoms and steep chamfers are not walls and appear in no draft figure.\n- `wall_draft_histogram_mm2`: wall area by draft band.\n- `wall_area_by_opening_mm2`: wall area by the direction its half withdraws\n  (`+pull`, `-pull`, `none`). Walls opening both ways along one pull is the\n  normal two-half case; which faces belong to which half is a parting-line\n  decision, not this.\n- `zero_draft_wall_area_mm2`: FLAT wall area parallel to the pull, pooled by\n  normal in `largest_zero_draft_faces`.\n- `zero_draft_tangent_area_mm2`: facets on a CURVED face that merely runs\n  parallel to the pull. A sphere is tangent to the pull along a line, not over\n  an area, so this figure scales with the tessellation — one sphere read\n  261 mm² coarse and 67 mm² fine. Report it as a tangency, never as a wall.\n  A wall that a fillet joins tangentially is NOT this: a flange rim with a\n  radius top and bottom stays zero-draft wall. The whole connected zero-draft\n  region is measured, not one triangle of it, so a flat wall the exporter split\n  into rows is not mistaken for one step of a sweep.\n- `min_wall_draft`: the lowest draft over the part's faces, above an absolute\n  area floor (0.1% of surface area), with its area and location. Tangent bands\n  are excluded. `min_facet_draft` is the single worst triangle; it moves with\n  the mesh, so quote `min_wall_draft`.\n- `drafted_wall_mean_draft_deg`: area-weighted over the drafted walls.\n\nEvery reported face carries `\"surface\"`: `\"flat\"` or `\"curved\"`, and the two are\npooled differently because they have to be. A flat face is pooled by normal and\nread exactly — every facet of a planar wall carries the surface's own normal. A\ncurved face is pooled by the SURFACE it lies on, because a facet of one is a\nchord: pooled by normal instead, a 3° cone came back as 318 separate \"faces\" of\n10 to 60 mm² and the lowest chord among them was reported as the part's minimum,\ngiving 0.46°, 0.68° and 1.91° for the same wall at three tessellations.\n\nA curved face is read at the **5th percentile of its own area**, not its mean\n(which would bury a real low-draft band on a sphere) and not its minimum (the\nchord outlier again). It also carries `spread_deg`, the scatter across the same\nface. On a face whose draft should be uniform that spread is the export's\ntessellation, and it BOUNDS the error: the reading is low by no more than the\nspread it declares. A spread over a degree means re-export finer before citing\nthe figure against a limit, and `reading_note` says so. Note that `area_mm2` for\na curved face is the whole face's area, not the area at that one draft.\n\nRead `min_wall_draft` against `zero_draft_wall_area_mm2` in the same report. If\nthere is zero-draft wall area and the minimum is not zero, the two disagree and\nthe report is wrong; say so rather than quoting one of them.\n\n### `undercuts`\n\nA straight two-half pull occlusion test. From each face a ray is cast along the\ndirection its mold half withdraws; faces the part itself blocks are candidates.\nZero-draft walls count only when both directions are blocked. Faces are split\nto a small maximum edge before casting, so the counted area follows the feature\nrather than the tessellation.\n\n`opens_toward` is the lean of a face's outward normal along the pull, not its\nmold-half assignment. The test assumes a straight two-half tool with the\nparting at the silhouette; a candidate may be resolved by a side action, a\nlifter, or another parting line, and that judgement, plus the full withdrawal\npath and shutoffs, is made in the review, not here.\n\nA file with several bodies is an assembly, not one molded part: each body is\ntested against itself, `body_count` and `per_body` appear, and nothing treats\none body as an obstruction for another. Say so in the report rather than\nreviewing an assembly as a part. A sealed internal void is NOT a second body —\ncoring a thick section is the standard molding fix, so it is counted in\n`internal_void_count` and the part stays one body. Nothing here measures a\nsealed void's own walls or reaches inside one.\n\n`probe_resolution_note` appears when the face budget stopped the split short.\nA mesh already over budget is cast unsplit, so undercut area follows the\ntessellation; re-export nearer the edge the note names before quoting the area.\n\n### `projection`\n\nSilhouette area along the pull, rasterised, with `raster_resolution_mm` sized\nto the part and reported. Plus depth along the pull and volume. The figure is a\ncount of covered cells, accurate to about one cell ring.\n\n### `pulls`\n\nRepeats the draft and undercut summaries for x, y, z AND the mesh's own\nprincipal axes. A part modelled off-axis has no good pull among the three\nworld axes, and reading a 2° draft as 12° under `--pull z` looks like a\nmeasurement rather than a mismatch; `principal0/1/2` are where its draft was\nbuilt. Choose a pull from measured area, not by eye.\n\n### Not measured here\n\n**Wall thickness.** Use `$dfam-check`'s `measure`, and ignore its `max_mm`: it\ncasts one ray along the face normal, which reads a plate's LENGTH rather than\nits thickness. The thin end — `min_mm` and the thin samples — is what it gets\nright, and the thin end is what fill depends on. Neither tool sees a sealed\nvoid. State the method with any thickness finding.\n\n## Review\n\nCollect resin grade/filler, nominal wall, pull direction, proposed parting line,\ntexture/cosmetic faces, critical fits, and tooling constraints. Treat unknown\nresin or pull direction as missing context, not permission to guess a verdict.\n\n- Review local wall thickness and transitions, including rib and boss junctions.\n  Sampling must state its coverage. Thick intersections can matter even when\n  nominal walls are consistent. Compare with resin-specific guidance.\n- Evaluate draft relative to the stated pull direction, separating core/cavity\n  sides and textured faces. Zero draft means a wall parallel to the pull direction;\n  top/bottom faces perpendicular to it should not be flagged as zero-draft walls.\n- Identify candidate undercuts and trapping features relative to the proposed\n  parting and pull. A normal-angle check alone is not a complete undercut test;\n  visibility/occlusion and the full withdrawal path matter. Distinguish a need\n  for side actions or inserts from impossibility of molding.\n- Review ribs, bosses, corner transitions, shutoffs, and likely ejection access.\n  Flag sink/warpage risks qualitatively unless a relevant analysis was run.\n- Discuss gate, vent, weld-line, packing, cooling, and shrinkage implications as\n  tooling questions; geometry review does not simulate filling or predict a\n  validated cycle time. Do not rescale the finished part for shrinkage without\n  the toolmaker's resin/process assumptions.\n- Preserve mating faces and functional dimensions when proposing draft or\n  coring. Identify which datum stays fixed and whether the proposed change\n  adds or removes material.\n\nThis workflow reviews conventional thermoplastic injection molding. For silicone,\novermolding, or insert molding, identify the additional process requirements\nand obtain applicable guidance rather than reusing thermoplastic limits blindly.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nIf a side wall has measured 0.5 degree draft (from `scripts/mold_tool.py\nmeasure --pull <axis>`) relative to the confirmed pull and the selected\ntexture/tooling specification requires 2 degrees, report the shortfall for that\nwall. Without a confirmed pull direction, report draft as unverified rather\nthan failing every vertical-looking face.\n","skills/dfm/references/sheet-metal.md":"# Sheet metal\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Sheet metal fabrication design guide](https://www.hubs.com/guides/sheet-metal-fabrication/) (Protolabs Network) for the design rules: K-factor and bend allowance, bend radius and relief, hems and curls, laser-cut hole and edge spacing, kerf, welding, tolerances.\n- [Sheet-metal guidelines](https://www.protolabs.com/services/sheet-metal-fabrication/design-guidelines/) (Protolabs) for that supplier's component sizes, material and thickness range, tolerances, bending guidelines, and common-feature rules for flanges, hems, reliefs and holes.\n\nCheck the current tables for the exact stock and tooling rather than importing\na universal minimum radius or flange length.\n\n## Review\n\nCollect alloy and temper, nominal stock thickness and units, cutting/forming process,\ncritical dimensions, and any shop tooling or bend table. Ask only for missing inputs\nthat change the current decision; otherwise state provisional assumptions.\n\n- Check whether each body represents constant-thickness sheet. Review matched\n  inner/outer surfaces; a bounding-box dimension is not a thickness measurement.\n- Identify bends, inside radii, angles, flanges, holes, slots, and reliefs per part.\n  Distinguish inside radius from outside radius. Record feature locations.\n- Compare radii, flange lengths, relief dimensions, and hole-to-bend distances\n  against the selected shop's rules. State the distance convention (bend tangent,\n  centerline, or edge) before comparing; those dimensions are not interchangeable.\n- For flat cutting, review closed contours, duplicate/intersecting curves, units,\n  and small features against cutting capabilities. A projection of a bent solid\n  is not an unfolded blank. Use an existing flat pattern or verified unfolding.\n- Compute bend allowance only with a specified convention and K-factor or bend\n  table. With bend angle theta in radians, BA = theta * (inside radius + K * thickness).\n  Do not treat a guessed K-factor as a production dimension.\n- Check bend order, tooling access, hems, hardware, grain direction, and collision\n  risks when relevant. Missing tooling or a valid unfold leaves these unverified;\n  a valid STEP solid alone does not establish forming feasibility.\n\nUse $dxf, when available, for drawing/flat-pattern work and $sendcutsend only for\nthat supplier's requested handoff. Neither a clean DXF nor a successful upload\nestablishes that all bends can be made.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA hole is 3 mm from a bend tangent, but the shop gives a 5 mm minimum measured\nfrom that same tangent: report a 2 mm shortfall and propose relocation or a\nsupplier-approved relief. If the drawing instead measures from the bend\ncenterline, resolve the convention before comparing.\n","skills/dxf/SKILL.md":"---\nname: dxf\ndescription: Generate, regenerate, and validate 2D DXF drawings from Python build123d sources. Use for DXF files, `.py` drawing scripts, @dxf models, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry. Open and visually review existing DXF files in CAD Viewer.\nlicense: MIT\n---\n\n# DXF generation and validation\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors.\n\nDrawings are build123d geometry, so a drawing build loads the CAD kernel like a\nSTEP build does (~2.5s cold; the warm daemon absorbs it on re-runs).\n`cadgen dxf snapshot` needs no Node at all: it flattens the drawing with `ezdxf`\n(which arrives with cadgen) and paints it in the bundled headless browser.\n\n## Purpose\n\nCreate or modify 2D DXF drawings from natural-language requirements or from CAD\ngeometry, generate validated drawing artifacts, and return checked outputs. A\nDXF drawing's source of truth is a Python file named `<name>.py` defining one\nparameterless `@dxf` model function.\n\n**A drawing is a model.** It has the same wrapper, record, freshness gate and\nbuild job a `@step` part has; its one output is the `.dxf` file; it has no\ngeometry tree (nothing links to a drawing). Every run writes the sibling\n`<name>.dxf` (or the `out=` the decorator names); an unchanged source is a\nno-op; a drawing that calls a part model — `bracket()` inside its body — is\nstale whenever that part's GEOMETRY changes and current when it does not;\n`cadgen store why <drawing>.py` explains the verdict; `--force` rebuilds it\nanyway. The CAD Viewer and `dxf snapshot` read the `.dxf` file itself, so the\nfile you hand a cutting service, the file the viewer draws and the file a\nsnapshot renders are one and the same.\n\n## The contract\n\n**A `@dxf` function takes no parameters and returns build123d 2D geometry. The\nengine writes the DXF.** You never construct a document, name a file, or place\nan entity — the same division of labor `@step` has.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n\nHOLE_D = 4.5\n\n\n@dxf\ndef gasket():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(60, 40)\n        bd.Circle(HOLE_D / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch          # bare shape -> the CUT layer\n\n\nif __name__ == \"__main__\":\n    gasket()\n```\n\n- **Bare shape** → one `CUT` layer. That is the whole contract for most drawings.\n- **`{layer: shape}`** → named layers, when the drawing genuinely has more than\n  one CAM operation (`CUT` / `ENGRAVE` / `SCORE`). A `Compound` whose children\n  are all labelled means the same thing.\n- **No parameters.** Dimensions are module constants (`HOLE_D = 4.5`) or\n  constants imported from the part the drawing derives from; a different\n  drawing is a different file.\n- **Text** is `bd.Text(...)` engraved OUTLINES on a marking layer, never a DXF\n  `TEXT` entity: cut and marking toolchains consume geometry, and font rendering\n  inside CAM is unreliable.\n- **Geometry must lie in the XY plane.** A face taken from a solid sits at that\n  solid's height; relocate it (`flatten.flatten_face(face)`, or\n  `bd.Location((0, 0, -z)) * face`). The engine REFUSES off-plane geometry rather\n  than silently writing its XY shadow.\n- **Output bytes are a function of the geometry.** Layers are sorted by name and\n  entities by geometric content, so an unchanged drawing rebuilds to an identical\n  file, cold or warm, on any machine.\n\n## The three DXF workflows\n\nCopy the full template for the applicable workflow from\n`references/generator-templates.md` when creating a new drawing.\n\n1. **Drafted from scratch** (gaskets, panels, templates, cut layouts with no 3D\n   model behind them): a `<name>.py` that builds sketches and returns them.\n\n2. **Flat pattern of a generated STEP part**: a drawing script beside the model\n   it derives from, with its OWN stem (one model per file — `bracket_drawing.py`\n   beside `bracket.py`). Import the model and call it, exactly as an assembly\n   composes a child: importing never builds, and inside the drawing's build the\n   call returns the part's geometry (building the part first if it is stale).\n\n   ```python\n   from cadgen import dxf, flatten\n   from bracket import bracket        # a child: tracked by its RESULT\n\n   KERF = 0.15\n\n\n   @dxf\n   def bracket_drawing():\n       return flatten.flat_pattern(bracket(), coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       bracket_drawing()\n   ```\n\n   The drawing's record pins the part's tree, so a part edit that changes its\n   geometry makes the drawing stale, and one that does not (a comment, a\n   refactor, a colour) leaves it current. Constants imported from the part\n   (`from bracket import THICKNESS`) are tracked by value the same way.\n\n3. **Flat pattern of an imported STEP** (a `.step`/`.stp` with no Python source):\n   read it with `cadgen.read_step` (warm from the store, the same geometry as\n   `build123d.import_step`). Like every file a build reads, it is an input:\n   replacing the vendor STEP makes the drawing stale on its own, with no `--force`.\n\n   ```python\n   from pathlib import Path\n\n   from cadgen import dxf, flatten, read_step\n\n   _HERE = Path(__file__).resolve().parent\n\n   KERF = 0.15\n\n\n   @dxf\n   def panel_flat():\n       panel = read_step(_HERE / \"imported\" / \"vendor_panel.step\")   # recorded input\n       return flatten.flat_pattern(panel, coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       panel_flat()\n   ```\n\n   **Never read a STEP this project generates.** Reading the `.step` a `@step`\n   model writes is not a loop, it is a drawing whose input changes on every run of\n   the model: the freshness gate can never say \"current\", every build is a full\n   rebuild, and the flat pattern depends on what the last run left on disk. Keep\n   source STEPs in an `imported/` directory beside the drawing, committed like any\n   other input — input path and output path being different files is the whole\n   rule. For a STEP this project DOES generate, use workflow 2 instead: import the\n   model script and call it, which is tracked by result and never touches an\n   artifact.\n\nOne model per file is the recommendation, and a drawing gets its own script:\na file MAY declare several models — two `@dxf` drawings, or a `@dxf` beside a\n`@step` — and each is its own record, output and job (a sole model writes\n`<file>.dxf`; models sharing a file write `<function>.dxf`), but they share the\nfile's closure, so editing one rebuilds them all. A drawing composes models,\nnever the reverse: calling a `@dxf` function from a `@step` body is just its 2D\ngeometry and links nothing. The viewer catalog is artifacts-only: scripts never\nlist; the `.dxf` the run writes is the entry the viewer renders.\n\n## Use this skill when\n\nUse this skill when the user asks for DXF files, 2D drawings, profiles, outlines,\ntemplates, gaskets, panels, flat patterns, or cut layouts for laser, plasma,\nwaterjet, or CNC routing.\n\nUse `$cad` for the 3D part or assembly a DXF derives from. Use `$sendcutsend` for\nSendCutSend-specific upload preflight.\n\n## Defaults\n\nUse these defaults unless the user specifies otherwise:\n\n- Units: millimeters. The engine sets them; a drawing never declares units.\n- Geometry lives at 1:1 scale in the XY plane.\n- Cut profiles close. Open contours belong on bend/engrave/reference layers —\n  generation validation enforces this (see Validation).\n- For CAD-backed parts, derive contours from the real topology with\n  `cadgen.flatten` rather than redrawing them: `planar_faces` selects,\n  `flatten_face` lays a face into XY exactly, `union_faces` fuses, and\n  `flat_pattern` does all of it in one call. Hand-drawn parametric outlines only\n  when there is no reliable 3D topology.\n- Kerf / tool-radius compensation is `flatten.offset_profile(shape, amount)` or\n  `flat_pattern(..., kerf=...)`; never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are exact OCC operations, so a\n  filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf included. A\n  profile that comes out as hundreds of short `LINE`s means something fell back\n  to the sampled path — investigate rather than accept it.\n- Layers carry intent: keep cut geometry and bend/fold lines on separate layers,\n  and include \"bend\" in bend-layer names so downstream tools classify them as\n  bends rather than cuts.\n- DXF layers are drawing structure, not STEP part/assembly structure.\n\n## Tool\n\n```bash\npython <drawing>.py [flags]                    # its __main__ calls the @dxf model, which writes the .dxf\ncadgen dxf snapshot <drawing.dxf> <file.png>   # render it\ncadgen store why <drawing>.py                  # why the drawing is stale or current\n```\n\n**Running the script (its `__main__` call) is the only door.** There is no\n`cadgen dxf build`: a `.dxf` has no derived state a command must materialize —\nthe file IS the product, and both the CAD Viewer and `dxf snapshot` draw it\nstraight from its own bytes. The drawing's gate makes a rebuild cheap: an unchanged\nsource whose `.dxf` still verifies and whose part children are unchanged is a\nno-op, and `--force` rebuilds anyway. The bytes are a function of the\ndrawing's GEOMETRY, so a cold run and a warm daemon worker write the same\nfile. Builds never wait on or cancel one another; a drawing that calls parts\nbuilds them in parallel like any parent.\n\nAn imported `.dxf` needs nothing at all — hand it straight to snapshot or the\nViewer.\n\nUse the active project Python interpreter; treat `python` as an interpreter\nplaceholder, and use `--help` for the full interface. Target paths resolve from\nthe command's current working directory; run from the workspace that owns the\nartifacts with cwd-relative target paths. Keep a drawing script in the same\ndirectory as the geometry it derives from, named `<name>.py`.\n\nFlags (a model script runs itself; there is no generation CLI):\n\n- `--force` — regenerate even when the recorded output is current.\n- `--verbose`, `--json`.\n\nA run answers on stdout exactly as a STEP model's does — `built DXF/plate_drawing.dxf`\nor `current DXF/plate_drawing.dxf` — with progress on stderr; `--json` makes the\nresult one JSON line (`outcome`, `document`, and `tree`, which is null for a\ndrawing) and the progress one JSON line per transition.\n\nOne script, one drawing: run each script you want built. Do not put output paths\nin the `@dxf` function's return value; `out=` on the decorator is the only\nplace a drawing names its destination (relative to the script).\n\n`cadgen dxf snapshot` draws a drawing flat, to a PNG still — the same picture\nthe CAD Viewer shows, from the same flattening, through the same drawing code:\n\n```bash\ncadgen dxf snapshot path/to/imported.dxf review.png\ncadgen dxf snapshot path/to/drawing.dxf review.png --appearance dark\n```\n\nIt takes the `.dxf` document only — a model script is refused by name (run\n`python <drawing>.py`, then snapshot the drawing it wrote). The whole drawing is\nfitted to the image and painted head on, in the pens the file declares; an\nentity with no pen of its own (ACI 7) takes the appearance's foreground on its\nbackground. The command flattens the drawing with `ezdxf` and renders it through\nthe shared snapshot CLI (`cadgen.snapshot_cli`) and the same headless browser\nruntime every rendering skill uses.\n\nOUT — the second positional — is written exactly as given, with a relative path resolved against the\ncurrent working directory. The target is deleted before the render starts and the\nfinished image is written atomically, so: reuse one name while iterating (every read\nis provably the render you just ran), name the iterations when you genuinely need to\ncompare two. Invalid request combinations fail before touching OUT; after a request is\naccepted, OUT is cleared first so a later failure leaves a missing file instead of\na stale image. A directory (`tmp/` as OUT) is the\ndon't-care case and gets a generated timestamped name inside it, printed on the\n`saved snapshot:` line.\n\nGrammar: `cadgen dxf snapshot TARGET [OUT] [flags]`. Flags: `--appearance\nlight|dark`, `--size-profile`, `--width`/`--height`, `--job`, `--debug`,\n`--json`. A drawing has no 3D scene settings: `--camera`, `--display`,\n`--mode` and `--view-labels` are not CLI flags. A job can frame a window of the\ndrawing with a plan-view `camera`\n(`{\"target\": [x, y, 0], \"position\": [x, y, d], \"up\": [0, 1, 0],\n\"orthographicHalfHeight\": h}`) and draw an `annotation` over it,\nin drawing coordinates: a Quick Edit prompt's views file is such a job, and\n`cadgen dxf snapshot --job <it>` renders the views the person saw again.\nOther camera poses, per-output cameras, scene display settings, `scale`, list mode,\noutput `label`/`viewLabel`, and `output.padding`/`viewLabels`/`tightFrame` are refused.\nA job's `output.renderScale` and `output.transparent` still apply.\nQuick Edit supplies this packet inline as JSON or by a `.snapshot.json` path; save\ninline JSON to a temporary file before using `--job`. Inspect supplied annotations\nbefore editing. Without annotations, the captured view is optional context.\n\nNo CLI inspects an existing `.dxf`. For entity/layer checks read it with `ezdxf`\ndirectly (it arrives with build123d), and `validate_dxf_file` for the drawing checks;\nreview geometry visually (see [Show the model](#show-the-model)).\n\n## Workflow\n\n1. Convert the request into a short brief: outline dimensions, holes and slots, layers, units, output path, and validation targets.\n2. Pick the workflow: drafted from scratch, flat pattern of a generated model (create and validate the 3D geometry with `$cad` first), or flat pattern of an imported STEP.\n3. Write or edit the `<name>.py` source with meaningful dimensions as named constants, reusing the model's geometry helpers instead of duplicating formulas.\n4. Run each drawing script directly (`python <drawing>.py`); do not sweep directories.\n\n```bash\npython path/to/source.py\npython path/to/source.py --force\n```\n\n5. Validate the generated DXF deterministically, then hand off and report.\n\n## Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nThe viewer renders saved DXF files as read-only 2D drawings; it never runs\ngeneration scripts. Drag to pan, wheel/pinch to zoom, double-click to fit. Its\nSelect, Pencil, Shapes and Measure feed Quick Edit, whose prompt names what the person\npicked as references into the drawing by entity HANDLE: `panel.dxf#h2F` is the\nmodelspace entity whose handle is `2F` (`doc.entitydb[\"2F\"]` in ezdxf); anything a\nblock reference draws is named by its INSERT, and a dimension is one entity. The\nprompt's chip reads it as the entity's type and layer (\"Circle on CUT\"). Measurements\nare in the unit `$INSUNITS` declares (\"units\" when it declares none).\n\n## Validation\n\nValidation happens IN generation, not after: every `@dxf` build runs the drawing\nchecks on the document the engine just serialized, before anything is written, and\na build with error findings fails. The checks: cut-layer profiles must close\n(polylines, circles, or chained line/arc loops), zero-length/degenerate entities are\nrejected, exact duplicate geometry (double-cut risk) is rejected, explicitly unitless\ndocuments are rejected, and an empty modelspace is rejected. Open geometry is allowed\nonly on bend/engrave/reference-intent layers (matched by name).\n\nThe same checks run post-hoc on any existing `.dxf` file — including one that\nnever came from a generator — through `cadgen.drawing_checks`:\n\n```python\nfrom cadgen.drawing_checks import validate_dxf_file\n\nfor finding in validate_dxf_file(\"path/to/file.dxf\"):\n    print(finding.render())\n```\n\nBeyond the built-in checks, verify requested dimensions with targeted `ezdxf` reads\n(entity counts by layer, drawing extents, every dimension the user specified) against\nthe generated sibling `.dxf` (or the `out=` path when one is declared), and\nreview geometry visually in the CAD Viewer:\n\n```python\nimport ezdxf\n\ndoc = ezdxf.readfile(\"path/to/source.dxf\")\nmsp = doc.modelspace()\ncut = msp.query('*[layer==\"CUT\"]')\nholes = msp.query('CIRCLE[layer==\"CUT\"]')\n```\n\nReport only checks that actually ran.\n\n## Handoff\n\nShow every drawing you created or changed ([Show the model](#show-the-model)).\nReport any failure explicitly.\n\nFinal responses should include generated files, returned viewer links, validation\nactually run, and assumptions.\n","skills/dxf/agents/openai.yaml":"interface:\n  display_name: \"DXF\"\n  short_description: \"Generate and validate 2D DXF drawings.\"\n  default_prompt: \"Use $dxf to create, regenerate, and validate explicit DXF drawing files from gen_dxf() Python sources, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","skills/dxf/references/generator-templates.md":"# DXF drawing templates\n\nRead this file when creating a new `<name>.py` drawing script. Copy the\ntemplate for the workflow that applies and replace the TODO markers.\n\nEvery template follows one contract: **the parameterless `@dxf` function\nreturns build123d 2D geometry and the engine writes the DXF.** You never\nconstruct a document, name a file, or think about entities.\n\n- Return a **bare shape** for a single-operation drawing — it lands on the `CUT`\n  layer.\n- Return **`{layer: shape}`** when the drawing genuinely has more than one CAM\n  operation (`CUT` / `ENGRAVE` / `SCORE`).\n- Geometry must lie in the **XY plane**. A face derived from a solid is at that\n  solid's height, so relocate it (`flatten.flatten_face`, or\n  `bd.Location((0, 0, -z)) * face`). The engine refuses off-plane geometry\n  rather than silently writing its XY shadow.\n- Validation runs during generation: cut layers must hold closed profiles, and\n  open geometry belongs on a bend/engrave/reference-named layer.\n- Every script ends with `if __name__ == \"__main__\": <drawing>()` — the call\n  is what builds it.\n\n## 1. Standalone drafting (DXF from scratch)\n\nFor pure 2D outputs — gaskets, panels, templates, cut layouts — with no 3D model\nbehind them. Keep meaningful dimensions as named constants.\n\n```python\n\"\"\"Standalone 2D drawing: <description>.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n# TODO: named dimension constants\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\nHOLE_D_MM = 4.5\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n        bd.Circle(HOLE_D_MM / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nTwo layers, when the part is both cut and marked:\n\n```python\n\"\"\"Standalone 2D drawing with a marking layer.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n    with bd.BuildSketch() as mark:\n        bd.Text(\"REV B\", font_size=6)\n    return {\"CUT\": cut.sketch, \"ENGRAVE\": mark.sketch}\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nText is engraved **outlines**, not DXF `TEXT` entities: cut and marking\ntoolchains consume geometry, and font rendering inside CAM is unreliable.\n\n## 2. Flat pattern of a generated STEP part\n\nFor a profile of a `$cad` model. The drawing imports the model and calls it,\nexactly as an assembly composes a child: importing a model never builds it,\nand the call inside the drawing's build returns the part's geometry (building\nthe part first if it is stale). The drawing's record pins the part's RESULT,\nso a geometry change in the part makes the drawing stale and a comment or\nrefactor does not; a constant imported from the part is tracked by value.\n\n```python\n\"\"\"Flat-pattern DXF drawing for <name>; geometry reused from <name>.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import dxf, flatten\n\nfrom <name> import <name>          # a child: tracked by its result; importing never builds\n\nTHICKNESS_MM = 6.0                 # TODO: the profile face's height\n\n\nKERF = 0.0\n\n\n@dxf\ndef drawing():\n    return flatten.flat_pattern(\n        <name>(),\n        coordinate=THICKNESS_MM,   # TODO: which face plane defines the profile\n        kerf=KERF,\n    )\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n`flat_pattern` is selection + flatten + union + optional kerf offset in one\ncall. Do the steps yourself when a part needs them apart — a bracket with\nflanges on several planes selects each one, flattens each with its own\ntransform, and unions the result:\n\n```python\n@dxf\ndef drawing():\n    part = bracket()\n    faces = [\n        *flatten.planar_faces(part, normal_axis=\"z\", normal_sign=1.0,\n                              coordinate_axis=\"z\", coordinate=3.0),\n        *flatten.planar_faces(part, normal_axis=\"y\", normal_sign=-1.0,\n                              coordinate_axis=\"y\", coordinate=0.0),\n    ]\n    return flatten.union_faces(flatten.flatten_faces(faces))\n```\n\n## 3. Flat pattern of an imported STEP\n\nFor a vendor `.step` with no Python source. Read it with `cadgen.read_step`;\nlike every file a build reads, it is an input — replacing the STEP makes the\ndrawing stale on its own, with no `--force`.\n\nThe face selection is a part-specific judgment call: pick the planar face(s)\nthat define the cut profile.\n\nNever point `read_step` at a STEP this project GENERATES — that is a model\nwhose input changes every time its sibling builds. Keep vendor files in an\n`imported/` directory beside the drawing (see the CAD skill's\n`step-generation.md`), and give the drawing its own stem:\n\n```python\n\"\"\"DXF profile of vendor_panel.step.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom cadgen import dxf, flatten, read_step\n\n_STEP_PATH = Path(__file__).parent / \"imported\" / \"vendor_panel.step\"\n\n\nKERF = 0.15\n\n\n@dxf\ndef drawing():\n    part = read_step(_STEP_PATH)\n    top_z = part.bounding_box().max.Z\n    return flatten.flat_pattern(part, coordinate=top_z, kerf=KERF)\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n## Common additions\n\n- **Bend / fold lines**: put them on a layer whose name contains `bend`\n  (`{\"CUT\": profile, \"BEND\": fold_lines}`). Open geometry is allowed there, and\n  downstream tools classify it as bends rather than cuts.\n- **Kerf / tool-radius compensation**: `flatten.offset_profile(shape, amount)`,\n  or the `kerf=` argument of `flat_pattern`. Positive grows the profile (cut\n  outside the line), negative shrinks it. Never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are OCC operations on the real\n  faces, so a filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf\n  included. If a drawing comes out as hundreds of short `LINE`s, something fell\n  back to the sampled path — check the union inputs rather than accepting it.\n- **Why did it (not) rebuild?** `cadgen store why <drawing>.py` prints the\n  drawing's gate: its closure files, each part it called with the pinned and\n  current tree, and whether the `.dxf` on disk is the one it wrote.\n","skills/engineering-drawing/SKILL.md":"---\nname: engineering-drawing\ndescription: Make an engineering drawing of a part as a PDF - orthographic views with hidden lines and centre marks, real dimensions, hole callouts, notes and a title block on ISO sheets, all projected from the part's geometry so the drawing follows the model. Use when the user asks for a drawing, a dimensioned sheet, shop or manufacturing drawings, a print, or \"2D views of this part\".\nlicense: MIT\n---\n\n# Engineering drawing\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nAn engineering drawing is a DOCUMENT derived from a part. `cadgen.eng_drawing`\nprojects views from the part's geometry, places dimensions between points ON\nthat geometry, finds centre marks on it, and lays the result out on an ISO\nsheet with a frame and a title block. Change the model, rerun the drawing\nscript, and the views, hidden lines and measured values move with it. Nothing\non the sheet is drawn by hand, and nothing on it is a number you typed unless\nyou chose to override a value.\n\n**The output is one PDF.** That is what a shop receives and what every\noperating system opens. No DXF is written, nothing is paired or linked, and\nthere is no CLI: the script is the interface. `$dxf` makes cut layouts and flat\npatterns, which are toolpaths, not documents; the two are different jobs.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation; later runs reuse it.\n\n## Workflow\n\n1. Get the part. `cadgen.read_step(Path(__file__).parent / \"../STEP/part.step\")`\n   reads the artifact a `@step` model wrote (run `python <model>.py` first); a\n   live build123d shape works just as well. The drawing documents geometry, it\n   does not build it.\n2. Write `<name>_drawing.py` beside the model, from\n   [the template](references/sheet-api.md#template). Import the model facts the\n   dimensions reference (hole pitch, overall size, wall) from the model module\n   rather than retyping them, so moving a feature moves its dimension.\n3. Place views with `sheet.three_views(part, iso=True)`, which returns FOUR\n   views: top, front and right in third angle with room for two rows of\n   dimensions, plus an isometric in the free corner. `sheet.three_views(part)`\n   returns three. Use `sheet.view(part, name, at=(x, y))` (sheet millimetres\n   from the bottom-left corner; A3 is 420 × 297, A4 is 297 × 210) only for a\n   custom layout.\n4. Dimension what a maker needs, in MODEL coordinates: overall size per view\n   (`view.overall()`), feature positions and sizes (`view.dim`, with `tol=`\n   or `fit=` where the design requires one), holes as callouts (`view.hole`:\n   thru, depth, counterbore, countersink, thread), and notes with leaders\n   (`view.note`). Leave `text` unset so the dimension is measured; give a\n   value only where the model does not define it. Leave `offset` unset too and\n   the dimension takes the next free row outside the view; pass one only to\n   place a dimension deliberately, and remember it is measured from the\n   dimension's own points, not the view's edge. Keep notes few and short; one\n   wider than the sheet's note column is refused, and `notes=` takes a LIST\n   (`notes=\"BREAK EDGES\"` is a sequence of eleven characters, so it is refused\n   too).\n5. Run `python <name>_drawing.py`. It prints the PDF it wrote, and **anything\n   it noticed while drawing** — read those first:\n   - *\"measures blank paper\"*: the dimension's model points do not land on the\n     view's geometry. Almost always the part was built from a corner and the\n     script assumed it was centred. Check the part's bounding box.\n   - *\"annotation overlaps\"*: two callouts print on top of each other, usually\n     one view's outermost dimension against the label of the view above it.\n     Pass a bigger `three_views(gap=...)` or place one with `offset=`. The text\n     is measured as the renderer draws it, glyph by glyph, so this fires on ink\n     and not on markup: a chain of toleranced dimensions is silent when the\n     values clear each other.\n\n   Steps 3 and 5 stand on their own: a sheet of views with no dimensions on it\n   yet is a drawing, and writes its PDF.\n\n   When running unattended, set `MPLCONFIGDIR` to a writable directory so\n   matplotlib's font cache does not warn.\n6. **Read the PDF** and fix what collides. Check that no dimension text sits on\n   a view, that hidden lines appear where features are behind faces, that every\n   hole has a centre mark, and that no leader crosses the part. Very small\n   dimensions (a few mm) put their value on their own extension line; dimension\n   the larger feature instead. Move `at=` or `offset=` and rerun.\n\n## What the sheet contains\n\n- Layers with meaning: `VISIBLE` (heavy outline), `HIDDEN` (dashed), `CENTER`\n  (centre marks), `DIM`, `NOTES`, `TITLE`, `SHEET` (frame), each printed at its\n  own weight — 0.5 mm down to 0.18 mm, so thick reads against thin on paper.\n  An edge is drawn once: a silhouette that the kernel returns as both visible\n  and hidden stays on `VISIBLE`. Hidden TANGENT transitions are not drawn at\n  all — a fillet running into a face, or the seam where a cylinder closes on\n  itself, marks nothing a shop can see, and drawn dashed it reads as a hidden\n  edge that is not there (on a bore, as a line down the hole's own axis).\n- Dimensions measured from the geometry, with filled arrowheads and witness\n  lines, and true-size values at any drawing scale.\n- A title block with title, part number, material, author, scale, units,\n  projection, revision, `SHEET n OF m` and the drawing function's name;\n  numbered notes above it (the general tolerance, when given, is note 1);\n  a revision table top-right when `revisions=` is given. Text longer than\n  its cell is set smaller, and cut with an ellipsis rather than overrun.\n- PDF bytes that are a function of the content: no creation date is stamped, so\n  an unchanged drawing rebuilds to an identical file.\n\n## What fails loudly\n\nThe script raises rather than writing a wrong or missing document:\n\n- Views that run off the frame, naming a scale that has been laid out and\n  verified to fit, or saying that no standard scale does.\n- A part argument the vocabulary does not define: an unknown view name, sheet\n  size or projection, `orientation=` other than `\"h\"`/`\"v\"`, a negative or zero\n  diameter, a counterbore that is not a `(diameter, depth)` pair, a `tol` that\n  is not a number or a pair, a hole that is both `thru` and given a depth,\n  `notes=` or `revisions=` given a bare string, a note wider than the sheet,\n  `angle()` legs that are collinear or zero-length, a non-finite `at=` or\n  `offset=`.\n- A missing renderer. The PDF is the drawing, so no matplotlib is a failure,\n  not a skipped half. A failed render leaves no file behind.\n- `out=` missing or not naming a `.pdf`, at import time.\n\n## Limits to state in the handoff\n\n- Tolerances come only from `tol=`, `fit=` and `general_tolerance=` that you\n  write; get the values from the design requirements or the user, never\n  invented. A dimension without one is nominal.\n- Views are orthographic projections with hidden lines from the kernel; no\n  sections, details, or auxiliary views yet. Say what a view does not show.\n- The sheet keeps callouts clear of the views it knows about, and reports\n  annotation that still prints over other annotation, but it does not check\n  annotation against line work. Read the PDF.\n","skills/engineering-drawing/agents/openai.yaml":"interface:\n  display_name: \"Engineering Drawing\"\n  short_description: \"Make a dimensioned engineering drawing of a CAD part, as a PDF.\"\n  default_prompt: \"Use $engineering-drawing to produce a dimensioned engineering drawing (views, dimensions, hole callouts, title block) of this part as a PDF.\"\n","skills/engineering-drawing/references/sheet-api.md":"# `cadgen.eng_drawing` reference\n\n```python\nfrom cadgen.eng_drawing import eng_drawing, Sheet\n```\n\n## Template\n\n```python\nfrom pathlib import Path\n\nimport cadgen\nfrom cadgen.eng_drawing import Sheet, eng_drawing\n# The facts the dimensions reference come from the model, not from this file.\nfrom enclosure import HOLES, L, H, W\n\n\n@eng_drawing(out=\"../PDF/enclosure_drawing.pdf\")\ndef enclosure_drawing():\n    part = cadgen.read_step(Path(__file__).parent / \"../STEP/enclosure.step\")\n    sheet = Sheet(\"A3\", title=\"ENCLOSURE\", part_number=\"ENC-001\", material=\"ABS\",\n                  revision=\"A\", general_tolerance=\"ISO 2768-m\",\n                  notes=[\"BREAK SHARP EDGES.\"])\n    # Third angle, placed from the part's size. iso=True returns FOUR views.\n    top, front, right, iso = sheet.three_views(part, iso=True)\n    top.overall()\n    top.dim((HOLES[0][0], W / 2, 0), (HOLES[2][0], W / 2, 0))   # unplaced: next free row\n    top.hole((HOLES[3][0], HOLES[3][1], H / 2), 3, depth=8, count=len(HOLES))\n    front.dim((L / 2, -W / 2, 0), (L / 2, -W / 2, H))\n    return sheet          # or [sheet_one, sheet_two] for a multi-sheet drawing\n\n\nif __name__ == \"__main__\":\n    enclosure_drawing()\n```\n\n## `@eng_drawing(out=\"<path>.pdf\")`\n\nDeclares the drawing. The function returns a `Sheet` or a list of them. Calling\nit renders one PDF at `out`, a page per sheet, and returns `[path]`.\n\n`out` is required and must name a `.pdf`; a relative path resolves against the\nscript. cadgen has no default location — where a document belongs is the\ncaller's decision. Both are checked when the module is imported, not when the\ndrawing runs.\n\nThere is no `eng_drawing` CLI verb. A `Sheet` is composed in Python — views,\ntheir placement, and dimensions between points on the part's geometry — and no\nargument list carries that, so the decorator is the whole surface.\n\n## The part\n\n`cadgen.read_step(path)` returns build123d geometry and reads warm from the\nstore. Anchor the path on the script\n(`Path(__file__).parent / \"../STEP/part.step\"`) — it resolves against the\nprocess's working directory otherwise. A live build123d shape works too; a\ndrawing takes geometry, not a file.\n\n## `Sheet(size=\"A3\", scale=1.0, title=..., part_number=\"\", material=\"\", revision=\"A\", author=\"\", units=\"mm\", projection=\"THIRD ANGLE\", notes=(), ink=\"mono\", text_height=3.5, general_tolerance=\"\", revisions=())`\n\n- `size`: `A4`, `A3`, `A2`, `A1`, `A0`, landscape. `width`/`height` in mm.\n- `scale`: drawing scale, 1 = 1:1, 0.5 = 1:2. Dimension values stay true size at\n  any scale — each view divides its measurement by its own scale.\n- `projection`: `\"THIRD ANGLE\"` (default) or `\"FIRST ANGLE\"`. The label and the\n  arrangement `three_views` produces are the same decision, so anything else is\n  refused rather than printed over the wrong layout.\n- `ink`: `\"mono\"` (default) is black ink with grey hidden lines, the printed look;\n  `\"color\"` keeps per-layer colours (red dimensions, green notes) for review.\n- `general_tolerance=\"ISO 2768-m\"`: written as note 1, \"TOLERANCES PER ISO 2768-m\n  UNLESS OTHERWISE SPECIFIED.\" Give one on any drawing a shop will quote from.\n- `revisions=[(\"A\", \"2026-09-01\", \"INITIAL RELEASE\"), ...]`: a revision table\n  (REV, DATE, DESCRIPTION) top-right inside the frame.\n- `sheet.three_views(shape, gap=None, iso=False)` → `(top, front, right)` placed\n  from the part's extents in the arrangement `projection` names, with the gap\n  sized for two rows of dimensions unless you pass one. `iso=True` adds an\n  isometric view in the free corner and returns it FOURTH — unpack four names.\n  Use `view()` with `at=` only for a custom layout.\n- `sheet.view(shape, name, at=(x, y), label=None, hidden=True, centre_marks=True, scale=None)`\n  → `View`. `scale` overrides the sheet's for this view (a pictorial drawn\n  smaller); say so in its label. `name` is one of `top`, `bottom`, `front`,\n  `back`, `left`, `right`, `iso`. The projected geometry is centred on `at`.\n\nA view whose geometry runs off the frame raises, naming the scale that fits.\n\nRunning the drawing also prints what it noticed: a dimension whose points miss\nthe view's geometry (it draws, but it measures blank paper — usually a part\nbuilt from a corner and dimensioned as if centred), and annotation printed over\nother annotation (usually one view's outermost row against the label of the\nview above it, which `three_views(gap=...)` fixes).\n\n## `View`\n\nAll points are MODEL coordinates (3-tuples; a 2-tuple means z = 0). Offsets\nare sheet millimetres.\n\n- `view.overall()`: overall width above and height left of the view, on the\n  OUTERMOST row of each side — the feature dimensions sit inside it.\n- `view.dim(p1, p2, offset=None, text=None, orientation=None, tol=None, fit=None)`:\n  linear dimension. `orientation` is `\"h\"`, `\"v\"` or None (whichever the pair\n  spans more); anything else is refused. `offset` left unset takes the next free\n  row outside the view, so unplaced dimensions never stack on each other or on\n  `overall()`. A number places the line that far from the farther of the two\n  points (not from the view's edge), and its sign picks the side; a dimension\n  between interior points then needs an offset that clears the geometry.\n  `tol=0.1` states ±0.1 as a proper tolerance (stacked, smaller text);\n  `tol=(0.05, 0.02)` states +0.05/-0.02 deviations; `fit=\"H7\"` appends an ISO\n  fit class. `text` overrides the value; `\"<>\"` inside it inserts the\n  measurement.\n- `view.hole(center, diameter, depth=None, thru=False, cbore=(dia, depth), csk=(dia, angle), thread=None, count=None, angle=45, tol=None, fit=None, label=None)`:\n  a hole callout in the standard symbols, e.g. `4× ⌀6.6 ↧12`, `⌀6 THRU`,\n  `⌴ ⌀11 ↧6.5` (counterbore), `⌵ ⌀12 × 90°` (countersink), or `M6x1 - 6H THRU`\n  when `thread` is given; `label=\"WHEEL\"` is appended; `angle` is where the\n  leader leaves the circle, in degrees from horizontal. Prefer it to\n  `diameter()` for any hole a shop drills. Every size must be a positive\n  number, and a hole is either `thru` or has a `depth`.\n- `view.diameter(center, radius, angle=45, text=None)` and\n  `view.radius(...)`: on a circular feature at `center`, plain value only.\n- `view.angle(vertex, p1, p2, offset=14)`: the angle at `vertex` between the legs\n  toward `p1` and `p2`; `offset` is the arc's distance from the vertex.\n- `view.note(text, at, offset=(10, 10))`: a leader from a model point to text.\n\n## Layers\n\n| Layer | Linetype | Weight | Holds |\n| --- | --- | --- | --- |\n| SHEET | continuous | 0.35 | frame |\n| TITLE | continuous | 0.25 | title block lines and text |\n| VISIBLE | continuous | 0.50 | visible edges |\n| HIDDEN | DASHED | 0.25 | hidden edges |\n| CENTER | CENTER | 0.18 | centre marks |\n| DIM | continuous | 0.18 | dimensions |\n| NOTES | continuous | 0.25 | notes, view labels, leaders |\n\nThey exist inside the ezdxf document the page is rendered from; the PDF carries\ntheir weights and dash patterns, not the layer names.\n","skills/gcode/SKILL.md":"---\nname: gcode\ndescription: Slice 3D models into printer-ready G-code with OrcaSlicer, the open-source slicer with built-in profiles for most FDM printers (Prusa, Bambu Lab, Creality, Voron and more). Use when the user wants an `.stl`, `.3mf` or `.obj` model sliced for their printer, as a sliced `.gcode.3mf` or plain `.gcode`, headless with OrcaSlicer's command line or by opening the model in OrcaSlicer. Never contacts a printer.\nlicense: MIT\n---\n\n# G-code\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nSlice with OrcaSlicer, headless (below) or by opening the model in the\nOrcaSlicer app for the user. The agent never contacts a printer: for a Bambu\nLab printer, hand the result to `$bambu-labs`; for any other printer, give the\nuser the `.gcode`.\n\n## Install OrcaSlicer\n\n- macOS: `brew install --cask orcaslicer`. The command line is\n  `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`.\n- Windows and Linux: install a release from\n  https://github.com/OrcaSlicer/OrcaSlicer/releases. The command is\n  `orca-slicer`; on Linux, run it from the AppImage or Flatpak.\n\nBelow, `<orca>` is that command.\n\n## Slice headless\n\nOrcaSlicer's command line (https://www.orcaslicer.com/wiki/cli/cli_mode) needs\ncomplete presets: it doesn't fill in a preset's parent settings, and it slices\nonly with a process whose compatible printers name the printer.\n`scripts/orca_presets.py` (Python 3, no dependencies) writes them from\nOrcaSlicer's presets, searching the user's own first, then OrcaSlicer's\nbuilt-in profiles.\n\n1. Find the user's printer, process and filament presets. Prefer the presets\n   the user prints with; never invent one, since a wrong bed size or\n   temperature can damage the printer.\n\n   ```bash\n   python scripts/orca_presets.py --list machine --match \"MK4S\"\n   python scripts/orca_presets.py --list process --match \"@MK4S 0.4\"\n   python scripts/orca_presets.py --list filament --match \"PLA @MK4S\"\n   ```\n\n2. Write the complete presets:\n\n   ```bash\n   python scripts/orca_presets.py --printer \"Prusa MK4S 0.4 nozzle\" \\\n     --process \"0.20mm SPEED @MK4S 0.4\" --filament \"Prusa Generic PLA @MK4S\" \\\n     --out presets\n   ```\n\n3. Slice. Give `--outputdir` an absolute path:\n\n   ```bash\n   <orca> model.stl \\\n     --load-settings \"presets/process.json;presets/printer.json\" \\\n     --load-filaments presets/filament-1.json \\\n     --arrange 1 --slice 0 \\\n     --outputdir /absolute/path/to/out --export-3mf model.gcode.3mf\n   ```\n\n   This writes `plate_1.gcode`, the plain G-code, and `model.gcode.3mf`, the\n   sliced 3MF, into that folder. The command line reads `.stl`,\n   `.3mf`, `.obj` and `.amf`; export STEP to STL or 3MF with `$cad` first.\n   Override one setting with `--<setting>=<value>`, using the setting's key\n   with hyphens for underscores, such as `--layer-height=0.16`.\n\n## Open in OrcaSlicer\n\nWhen the user would rather pick presets and slice themselves, or no preset can\nbe found, open the model in the app: `open -a OrcaSlicer model.stl` on macOS,\n`Start-Process model.stl` on Windows when OrcaSlicer opens that file type, or\n`orca-slicer model.stl` on Linux. The app also reads STEP.\n\n## Check before printing\n\n- `<orca> model.stl --info` prints the model's size; it has to fit the bed.\n- Check the slice's settings, which OrcaSlicer writes at the end of the G-code:\n\n  ```bash\n  grep -E '^; (printer_model|nozzle_diameter|filament_type|nozzle_temperature|hot_plate_temp|bed_temperature) =|printing time' out/plate_1.gcode\n  ```\n\n  Confirm they match the user's printer and material before anyone prints it.\n\n## Hand off\n\n- A Bambu Lab printer: `$bambu-labs`, with the `.gcode.3mf`.\n- Any other printer: the user prints the `.gcode` from their printer's app, its\n  web interface (PrusaLink, OctoPrint, Mainsail, Fluidd) or an SD card.\n","skills/gcode/agents/openai.yaml":"interface:\n  display_name: \"G-code\"\n  short_description: \"Slice models into G-code with OrcaSlicer\"\n  default_prompt: \"Use $gcode to slice this model for my printer with OrcaSlicer.\"\n","skills/sdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2FSKILL.md -->","skills/sdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Fagents%2Fopenai.yaml -->","skills/sdf/references/design-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fdesign-ledger.md -->","skills/sdf/references/examples.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fexamples.md -->","skills/sdf/references/frame-semantics.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fframe-semantics.md -->","skills/sdf/references/interoperability.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Finteroperability.md -->","skills/sdf/references/llm-guardrails.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fllm-guardrails.md -->","skills/sdf/references/sdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsdf-workflow.md -->","skills/sdf/references/smoke-tests.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsmoke-tests.md -->","skills/sdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fvalidation.md -->","skills/sendcutsend/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2FSKILL.md -->","skills/sendcutsend/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Fagents%2Fopenai.yaml -->","skills/sendcutsend/references/official-sources.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Fofficial-sources.md -->","skills/sendcutsend/references/report-template.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Freport-template.md -->","skills/srdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2FSKILL.md -->","skills/srdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Fagents%2Fopenai.yaml -->","skills/srdf/references/authoring-contract.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fauthoring-contract.md -->","skills/srdf/references/disabled-collisions.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fdisabled-collisions.md -->","skills/srdf/references/end-effectors.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fend-effectors.md -->","skills/srdf/references/planning-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fplanning-ledger.md -->","skills/srdf/references/srdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fsrdf-workflow.md -->","skills/srdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fvalidation.md -->","skills/step-parts/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2FSKILL.md -->","skills/step-parts/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Fagents%2Fopenai.yaml -->","skills/step-parts/references/step-parts-api.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Freferences%2Fstep-parts-api.md -->","skills/urdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2FSKILL.md -->","skills/urdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Fagents%2Fopenai.yaml -->","skills/urdf/references/authoring-contract.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fauthoring-contract.md -->","skills/urdf/references/design-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fdesign-ledger.md -->","skills/urdf/references/frame-semantics.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fframe-semantics.md -->","skills/urdf/references/inertials.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Finertials.md -->","skills/urdf/references/meshes.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fmeshes.md -->","skills/urdf/references/urdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Furdf-workflow.md -->","skills/urdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fvalidation.md -->"},"files":{".claude-plugin/marketplace.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fmarketplace.json -->",".claude-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fplugin.json -->",".codex-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.codex-plugin%2Fplugin.json -->",".cursor-plugin/plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.cursor-plugin%2Fplugin.json -->","AGENTS.md":"# AGENTS.md\n\nInstalling text-to-cad rather than developing it? Follow the Install section of\n[README.md](README.md).\n\nThis repo is a workbench for CAD-related agent skills. Treat `skills/` as the\nproduct and `models/` as the shared fixture/artifact area.\n\n## Branch First\n\n`main` is the only branch you develop on: the source tree, and what releases\nare cut from. Branch from `main` and open PRs against `main`; never push it\ndirectly. There is no development symlink layout — every path in the tree is the\nreal file. Installers follow `latest`, and claude.ai's directory\n`claude-plugin`: the plugin alone, one commit per release,\nwritten only by `Publish Release` once the release is on PyPI (see below); never\ncommit to them. `main` stays an installable plugin too: every manifest and MCP\nconfig lives at its root.\n\n## Release Workflow\n\nA pull request that changes `VERSION` is a release, and merging it releases it.\nNever bump during normal development work, and never pick the bump yourself: if\na request to make or ship a release does not name patch, minor, major, or an\nexact version, ask which one. A release pull request comes one of two ways:\n\n- **Its own pull request**, for a release of what `main` already has — what\n  \"make a release\" means unless the user names a pull request to carry it:\n  dispatch `Prepare Release`\n  (`gh workflow run release-prepare.yml --ref main -f bump=<patch|minor|major>`,\n  or `-f set_version=X.Y.Z`). It opens `release/X.Y.Z` with the bump and never\n  merges it.\n- **On the pull request that should carry it**: on its branch,\n  `scripts/release/bump-version.sh <patch|minor|major|X.Y.Z>` sets `VERSION` past\n  `main`'s and stamps the derived metadata and every `cadgen==` pin with it.\n\nMerge a release pull request only on the user's explicit word, once its checks\npass: the merge is the release.\n\n- `Test` runs every job for a `VERSION` change, so a release is tested whole.\n  Its Version Check vets the bump: a branch of this repository only (a fork\n  cannot release), a version past `main`'s and the latest tag, every stamp in\n  step.\n- `Publish Release` (`release-publish.yml`) runs on the merge. It finds the\n  `Test` run that recorded the merged tree as tested in full (else it runs every\n  `Test` job on it first), builds the plugin ZIP, the bundle and the `cadgen`\n  wheel and sdist, installs and exercises the wheel, uploads the pages' source\n  maps to PostHog and the wheel to PyPI, and\n  waits until PyPI's index lists it. Only then does it commit the plugin alone\n  onto the branches installers follow (`scripts/release/plugin_branch.py`):\n  `latest`, which every install command names, and `claude-plugin`, which\n  claude.ai's directory tracks. After them it deploys the docs site and tags\n  (`v<VERSION>`; releases before 0.5.0 are bare\n  `0.4.x` tags) and GitHub-Releases the release commit, with the wheel and sdist\n  that went to PyPI attached as release assets, plus the plugin ZIP that a\n  person uploads to OpenAI's plugin portal, which has no API.\n\nTo finish a run that stopped partway, dispatch `Publish Release` on `main`\n(`publish=false` leaves the GitHub Release as a draft): it publishes the commit\nthat last moved `VERSION`, if that version has no tag yet. `build-test` is the\nrehearsal branch — `Prepare Release` with `-f target=build-test` opens a bump\nagainst it, whose merge is built and installed with no PyPI, branches, docs or\ntag — and is never a release; use it only when the user explicitly asks to test\nthe pipeline.\n\nThe standalone `Deploy Docs` workflow redeploys the docs site from a ref\n(default `main`, or a release tag) without running a release.\n\ncadgen runs as ONE launch command everywhere, `uvx --no-config --managed-python\n--python 3.13 --from cadgen==<VERSION> <tool>` (`cadgen._internal.launch`): the\nplugin server configs and every cadgen skill's SKILL.md carry it, the release\nstamps every pin (`sync-version.mjs`), and `scripts/release/check-version.sh`\nand the tests assert they all equal `VERSION`. uv keeps one installation per\nrequirement and cadgen names its warm daemon after that installation, so the\nserver, every thread and every skill command share one daemon. In a checkout,\ninstall `requirements-dev.txt` (the editable cadgen) and test in agent apps with\n`scripts/install/dev_install.py`, which points both at the checkout.\n`models/` stays on `main` as plain files; nothing installs it.\n`scripts/github-workflows/check-builds.sh` enforces the shipping contract on\nevery push: no tracked symlink, no `.gitattributes` rule that rewrites files at\ncheckout or changes the archive (so no Git LFS; claude.ai's plugin directory\nrefuses them), every tracked file under 5 MiB, no skill reaching into a repo root.\nSee the Releases section in `CONTRIBUTING.md` for the full flow, the resume\npath, the rehearsal, and local/manual fallbacks.\n\n## Repo Map\n\n- `skills/`: agent skills and their references/scripts.\n- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, and the\n  [Agent Plugins](https://agent-plugins.org) standard's `plugin.json` and `mcp.json`:\n  agent plugin manifests. The repository root is the plugin package; its skills are\n  `skills/` directly. Installers take it from the `latest` branch, which\n  `scripts/release/plugin_branch.py` builds from this tree at each release.\n- `models/`: sample and durable CAD/robot-description fixtures.\n- `apps/web/`: the CAD Viewer's React client (its backend is `cadgen.viewer`).\n- `apps/mcp/`: the CAD app agent hosts render: tabs in Codex, cards in Claude Desktop (its server is `cadgen mcp`).\n- `packages/core`: `@text-to-cad/core`, shared CAD/runtime/client code without React.\n- `packages/ui`: `@text-to-cad/ui`, the shared FileViewer, renderers, controls and styles.\n- `packages/cadgen`: the published distribution — STEP/GLB/topology generation,\n  the skill CLI parsers, the CAD Viewer backend + client, and the Node/browser\n  runtimes it executes.\n- `apps/docs/`: documentation site.\n- `apps/api/`: `api.texttocad.dev`, its own Vercel project: cadgen's version feed (read from PyPI) and the CAD app's consented analytics.\n- `tests/`: root-owned test suites for skills, packages, viewer services, and\n  repo-wide policy.\n- `scripts/`: durable repo commands grouped by purpose.\n\n## Repo Rules\n\n- Boundaries and design laws live in each package's README: read\n  `packages/cadgen/README.md` (the laws), `packages/core/README.md`, `packages/ui/README.md`,\n  `apps/web/README.md`, `apps/docs/README.md` and `apps/api/README.md` before changing\n  generation, rendering, storage, layout, or public interfaces.\n- A README holds the laws; the mechanism each law constrains lives one link\n  away, and the README names the link. Read the README, then follow the one\n  link — not the tree. What exists:\n  - `packages/cadgen/`: `STORE.md` (the store contract — sectioned, with a\n    table of contents), `SNAPSHOTS.md` (snapshot `--debug` timings).\n  - `packages/core/docs/`: `render-pipeline.md`, `resource-ownership.md`,\n    `tube-deformation.md`.\n  - `packages/ui/docs/`: `settings-ui.md` (BINDING for any settings control),\n    `render-types.md`, `render-mode.md`, `lod.md`, `storage.md`, `backend.md`.\n- Ships-alone law: `packages/cadgen` (the built PyPI wheel) works in isolation\n  outside this repo, so its markdown must not refer to anything outside the\n  package — enforced by `tests/python/global/test_package_boundaries.py`.\n  Repo-development guidance for it goes in `CONTRIBUTING.md`.\n\n- The README and the docs site's homepage (its copy: `apps/docs/src/lib/content.ts`)\n  share their structure, install message and commands: change them together.\n  The README says more: the steps to install it yourself, and fuller notes under each install\n  (`apps/docs/README.md`).\n- Keep root guidance short. Put domain workflows, CLI details, and validation\n  policy in the relevant `skills/<skill>/SKILL.md` or `references/` file.\n- Keep relevant Markdown docs current when changing behavior, commands, or repo\n  layout, but do not bloat `AGENTS.md`; use it only for durable repo-level\n  rules and pointers.\n- Read `CONTRIBUTING.md` before committing, rebasing, resolving generated-file\n  conflicts, or bumping release versions.\n- A skill must not import another skill, a `skills/` root module, or a\n  repository-root module, and must not add `skills/`, the repository root, or a\n  sibling skill directory to `sys.path`, `PYTHONPATH`, `NODE_PATH`, or any other\n  runtime lookup path. Skills are independent of each other, not of everything.\n- Shared runtime comes from the **`cadgen` distribution**. A skill that uses it\n  defines `cadgen` and `python` in its SKILL.md as the launch command above,\n  pinned to `VERSION` (the release stamps every pin); no skill installs cadgen\n  any other way (no `requirements.txt` naming it), because a second installation\n  is a second daemon. Skills do not vendor it: a skill script is a thin entrypoint whose\n  parser and behaviour live in `cadgen.cli`. Playwright is a cadgen dependency and\n  the first snapshot fetches its headless browser, so no skill has a browser step. cadgen carries\n  the JavaScript it executes too (Node builders, the snapshot browser bundle,\n  the CAD Viewer client), so a skill ships no runtime of its own. Not every\n  skill needs cadgen (bambu-labs, dfam-check, dfm, gcode, sendcutsend,\n  step-parts are cadgen-free); do not add the dependency to a skill that never invokes it.\n- Keep samples and manual CAD/robot-description validation artifacts under\n  `models/`. Automated tests must not read, build or import that sample corpus:\n  generate small fixtures in fresh temporary directories or use tiny test-owned\n  fixtures, with their own cache stores and cleanup. Repo `tmp/` is fine.\n  Enforced by `tests/python/global/test_tests_are_self_contained.py`.\n- Every test runs in CI. Each test file is reached by a runner under\n  `scripts/test/`, every runner is called by a `test.yml` job on the changes that\n  can break it, and a collector that finds nothing fails the run rather than\n  reporting a group that never ran. A test no CI job runs is dead: wire it in or\n  delete it. There are no manual-only test gates. Enforced by\n  `tests/python/global/test_ci_workspace_selection.py`.\n- Tests and CI are short and succinct. Test a contract, a user flow or a fixed bug,\n  once, at the cheapest level that exercises the real path: a unit or jsdom test\n  first, a real browser (WebGL) only for what needs one. Await the condition, never\n  a fixed sleep or a wall-clock bound; a flaky test is fixed or deleted, never\n  retried. CI time is a budget: the `web` job stays within 7 minutes, and a\n  change that lengthens any job says what it costs and why in its PR.\n- Benchmarks under `scripts/bench/` are manual and their output is never\n  committed: reports, logs, profiles and screenshots go to an ignored `tmp/`.\n  Only their pure helper units run in a test runner.\n- The Python floor is `requires-python` in `packages/cadgen/pyproject.toml` and\n  nowhere else. Every cadgen source is parsed against that floor, so syntax\n  newer than it fails here rather than at `pip install` time on a user's\n  interpreter; raising the declared minimum relaxes the check automatically.\n- Reserve `scripts/` for durable repo commands. Do not write temporary,\n  one-off, or local-only helper scripts there; use `tmp/` or `/tmp` instead.\n- cadgen's packaged runtime (`_runtime/node`, `_runtime/browser`,\n  `_runtime/viewer`, and the file tracer every build loads, `_runtime/native`)\n  is BUILT, never committed: the whole directory is\n  gitignored and ships only inside the wheel. Build it with the one bundle\n  entry point, `scripts/bundle/bundle.sh`; `bundle.sh --check` builds it and\n  asserts every required output. Call `scripts/bundle/cadgen-runtime.sh`\n  directly only when debugging one stage.\n- Installs always work from `main`. A command that names no branch installs `main`\n  as it is, so every manifest, MCP config and the marketplace catalog stay at its\n  root, the catalog lists the plugin at `./`, and nothing on `main` points an\n  installer at another branch. The documented commands name `latest` instead: the\n  plugin alone, one commit per release, which `Publish Release` writes once the\n  release is on PyPI, so it installs lean (no monorepo, no npm install of the\n  workspace) and is never ahead of PyPI. `latest` is a channel, so a slower\n  `stable` can join it later. `tests/python/global/test_plugin_manifests.py`\n  holds both halves.\n- Never let a symlink reach the published tree. Agent installers disagree about\n  symlinks and one loses data silently: the Skills CLI dereferences them, Claude\n  Code preserves them, and Codex `plugin add` drops them with no error, shipping\n  a skill with missing files. `scripts/github-workflows/check-builds.sh` enforces\n  this; do not relax it.\n- The CAD Viewer is `cadgen viewer`: the server is `cadgen.viewer` (Python, in\n  `packages/cadgen`), the React client's source is `apps/web/` and its build\n  ships in the wheel at `cadgen/_runtime/viewer` (built, never committed; a\n  checkout serves `apps/web/dist`). The CAD, DXF and robot-description skills document that verb directly.\n  Nothing in `cadgen.viewer` imports the CAD kernel at module scope — the one\n  kernel action, importing a foreign STEP, is a compile job in cadgen's build\n  pool, never work the server process does.\n  Keep repo-level tooling in `scripts/`, not under `apps/web/`.\n- `packages/core` stays non-React. Shared FileViewer/renderers belong in\n  `packages/ui`; host workflow state belongs in apps. Apps never import another\n  app and shared packages never import apps. Root npm workspaces consume compiled\n  package exports; do not add source aliases or nested lockfiles. Preserve app\n  UI/UX and functionality during restructuring; changes are pure refactors.\n- Shared UI must stay platform-agnostic. Apps implement environmental effects;\n  shared features use injected capabilities and named extension slots. Before\n  extending these interfaces, read [the viewer host contract](packages/ui/docs/viewer-host.md).\n- `packages/cadgen` is the whole distribution, not just the Python: artifact\n  generation, the CLI parsers behind every skill command (`cadgen/cli`), the warm\n  build daemon (`cadgen/daemon`), and\n  the JS/SPA assets it executes (`cadgen/_runtime`, built by\n  `scripts/bundle/cadgen-runtime.sh`). Skills consume it as an\n  installed distribution.\n- Create lightweight shared Python packages under `packages/` when a helper\n  should not inherit heavier package dependencies.\n- Use path-targeted search, validation, and `git status`; avoid broad scans over\n  generated CAD artifacts unless the task requires them.\n- Treat `VERSION` as the canonical release version. Do not hand-edit duplicate\n  package, plugin, lockfile, or Python `pyproject.toml` versions;\n  `scripts/release/bump-version.sh` and `scripts/bundle/bundle.sh` stamp them\n  from the canonical version.\n\n## Environments\n\n- Prefer `./.venv/bin/python` for CAD Python work.\n- Keep new branch checkouts and git worktrees lightweight by default. Do not\n  copy `.venv/` or `models/` through `.worktreeinclude`; recreate `.venv/`\n  inside the worktree only when Python dependencies are needed for the workflow.\n- In Codex or Claude Code worktrees, prefer the skill instructions and scripts\n  under the current worktree's `skills/` directory over globally installed\n  skills or plugins from another checkout.\n- Test skills and the plugin in an agent app with\n  `scripts/install/dev_install.py <host>` (`CONTRIBUTING.md`, \"Test In Agent\n  Apps\"). One copy of the plugin per app: never the development install beside\n  the published plugin, or beside the skills installed loose.\n- Install dependencies only for the workflow being changed.\n- Do not commit `.venv/`, `node_modules/`, caches, `tmp/`, local credentials, or\n  printer config.\n\n## Checks\n\nRun the smallest path-targeted check that covers the change. Use broad wrappers\nwhen touching shared surfaces or before handoff:\n\n- Code tests: `scripts/test/test.sh` (JS, API, Python, then policy).\n- Focused runners: `scripts/test/test-js.sh`, `scripts/test/test-api.sh`, `scripts/test/test-docs.sh`,\n  `scripts/test/test-python.sh`, `scripts/test/test-global.sh`.\n  `test-python.sh` takes `--select cadgen|viewer|skills|all` and\n  `--print-weights`; `test-js.sh` takes `--select core|ui|web|mcp|all`. See\n  `scripts/README.md`.\n- In GitHub Actions, `test.yml` runs one job per concern, each only when the\n  change reaches it: `scripts/github-workflows/select_checks.py` maps every\n  changed path to the tests that read it, and the Python jobs run just those\n  files. A test that reads a new kind of path gets a rule there\n  (`CONTRIBUTING.md#ci`); an unknown path, `VERSION`, `packages/core`, the\n  lockfiles and the test machinery run everything, as does a manual dispatch.\n  Required check names stay stable.\n- Canonical release version: `scripts/release/check-version.sh`\n- Packaged runtime builds and is complete: `scripts/bundle/bundle.sh --check`\n- CAD Viewer or shared packages: build exports with `npm run build:packages`,\n  then `npm --prefix packages/core test`, `npm --prefix packages/ui test`,\n  `npm --prefix apps/web run test`, `npm --prefix apps/web run build`.\n  The Viewer is two languages and `npm run test` covers only the client — the\n  backend's suite is `tests/python/packages/cadgen/viewer`, run by\n  `scripts/test/test-python.sh`. Touching `cadgen/viewer/` means running that.\n- Docs site: `npm --prefix apps/docs run check`\n- API (`api.texttocad.dev`): `npm --prefix apps/api test`\n- Targeted Python tests: `./.venv/bin/python -m unittest <changed test paths>`\n\nWhen a task changes what the bundlers consume, run `scripts/bundle/bundle.sh`\nand confirm the change lands in the built runtime. There is nothing to commit:\n`_runtime/` is gitignored end to end, so what a reviewer reads is the source and\nwhat a user gets is the wheel the release builds from it.\n\n## CAD Viewer\n\nThe app-facing playbook lives in `apps/web/README.md`: launcher contract\n(reuse, ports, `--new`), dev vs prod, and the catalog/link-verification\ngotchas. The repo-side half — the lightweight-worktree recipe and\nroot workspace dependencies — lives in `CONTRIBUTING.md` under \"Viewer Development\nIn This Repo\". Read them before starting, stopping, or debugging a Viewer.\nNever stop an instance you did not start; packaged-runtime checks go\nthrough `scripts/bundle/bundle.sh`.\n\n## Git\n\nNo Git LFS, and every file under 5 MiB: the repository root is the plugin, and\nclaude.ai's plugin directory refuses files a filter rewrites at checkout, so\nheavyweight media stays out of the tree. Local hooks live in `.githooks` and\ndelegate build checks through `scripts/git-hooks/pre-commit`.\n","apps/web/skills/smui/SKILL.md":"# Shared UI styling for the CAD Viewer\n\nThe CAD Viewer is a Vite and Tailwind v4 host of `@text-to-cad/ui`. Shared\ncomponents and CAD controls live in that package; the web app owns its top bar\nand browser-specific composition. In a repository checkout, read\n`packages/ui/README.md` before changing a\nshared control.\n\nThe package migration is a pure refactor. Preserve the current appearance and\ninteraction behavior of each app: typography, spacing, borders, surfaces,\nresponsive layout, loading states and saved preferences. Moving a component\ndoes not authorize a redesign. Compare changes against the existing app in\nboth color schemes and at narrow widths.\n\n## Tokens and ownership\n\n- Canonical tokens live in\n  `packages/ui/src/styles/tokens.css`.\n  The current palette is neutral, with light and dark variants and the existing\n  radius scale. Reuse those values and semantic utilities such as\n  `bg-background`, `text-muted-foreground`, `border-border` and\n  `text-destructive`.\n- The web entry stylesheet,\n  [globals.css](../../src/client/styles/globals.css), imports\n  `@text-to-cad/ui/tokens.css` and `@text-to-cad/ui/styles.css`. Shared utility classes\n  and assets are built by the UI package; apps do not scan another package's\n  source or duplicate its token definitions.\n- The host supplies the color scheme and applies its existing `.dark` behavior.\n  Keep the CAD scene theme separate from host appearance, using the existing\n  preference adapter and shared renderer contract.\n- Preserve the current font, text case, radius, opacity and shadows of the\n  affected component. There is no additional Nord palette, glass-surface rule,\n  uppercase-label rule or monospace requirement to apply.\n- Import reusable controls through `@text-to-cad/ui/primitives/*` and `cn` through\n  `@text-to-cad/ui/utils`. The class-merging helper lives in\n  `packages/ui/src/lib/utils.js`.\n  Keep existing host-specific controls when their appearance or behavior differs.\n\n## Controls and interaction\n\nUse the existing shared primitives and variants. Preserve their keyboard\nnavigation, focus styling, labels, disabled states and pointer behavior when\nmoving or wrapping them. Icon-only controls need an accessible name; use the\nexisting Lucide icons and tooltips. Status and error text must convey meaning\nwithout relying only on color.\n\nFor rows in a CAD file's panels (its own panel and Display), follow\n`packages/ui/docs/settings-ui.md`. It defines the\nexisting row types, control widths, units, section structure and accessible\nlabel conventions. Reuse those CAD controls inside the renderer instead of\nadding a second settings layout.\n\nKeep loading, empty and error states in their existing positions. Preserve\nfocus and dismissal behavior for menus and dialogs, and retain reduced-motion\nhandling for animated indicators. A visual cleanup must not remove feedback,\nchange a control's availability or alter selection and navigation behavior.\n\n## Validation\n\nCheck the affected app and shared component in light and dark modes, at the\nnormal and narrow layouts, and with keyboard as well as pointer input. For CAD\nchanges, include the file's panels, toolbar, loading/error and preview\nstates; the `packages/ui/docs/render-types.md` cover\ncross-format and theme behavior. Rebuild shared package output before checking\nan app that consumes it, following the package README. Do not use a migration\nor documentation update to change the styling baseline.\n","packages/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2FREADME.md -->","packages/cadgen/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcadgen%2FREADME.md -->","packages/core/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcore%2FREADME.md -->","packages/core/docs/render-pipeline.md":"# Render Pipeline\n\n`@text-to-cad/core` exposes a staged render pipeline for shared viewer, docs, and generated\nsnapshot browser-runtime work. A STEP document goes:\n\n```js\nconst source = await loadSource(input, sourceOptions);\nconst model = buildModel(THREE, source, modelOptions);\nconst viewport = renderModel(THREE, model, viewportOptions);\nconst result = await captureModel(viewport, captureOptions);\n```\n\nEvery other file family (GLB, STL, 3MF, URDF, SRDF, SDF) is drawn by ITS OWN scene\nbuilder, the one the viewer's renderer for it calls too: the snapshot page loads the\nfile with that family's loader, builds the scene with that family's builder, dresses it\nin the look the viewer's viewport would, and hands it to the same `renderModel` and\n`captureModel` (`common/headlessScene.js`). No family is flattened into mesh data for a\nrender.\n\nThe stages keep ownership narrow:\n\n- `loadSource` owns STEP source and sidecar loading plus file-kind validation.\n- `buildModel` owns the CAD object graph, records, selection, clipping,\n  materials, topology/display edges, and STEP parameter effects.\n- A family builder owns its scene: `createGlbScene` (`lib/render/glbScene.js`),\n  `buildMeshScene` (`lib/render/meshScene.js`), `createRobotScene`\n  (`lib/urdf/robotScene.js`). Each returns the scene contract of\n  `lib/viewer/sceneContract.js`, and each scene gives a sibling (`createSibling()`): a\n  second scene of the same file with its own objects, materials and look over the first's\n  geometry (`lib/render/sharedGeometry.js`), which the viewer's Preview draws beside\n  its editor. A GLB's sibling is a SkeletonUtils clone wearing copies of the file's authored\n  materials, kept before any look, with a sibling `document` for its own routine; a robot's\n  has its own links and pose, and its own vertex colours. Ownership is\n  [resource-ownership §1](resource-ownership.md#1-shared-ownership-and-disposal).\n- `renderModel` owns renderer, scene, camera, lighting, background, floor,\n  framing, resizing, and render loop concerns.\n- `captureModel` owns deterministic snapshot outputs without filesystem writes.\n\nThe CAD skill's Python snapshot CLI remains responsible for job parsing, path\nresolution, Playwright routing, and writing returned outputs to disk.\n\n## Modules\n\n### `common/viewSettings.js` and `common/sceneSettings.js`\n\n`normalizeViewSettings(display)` is the strict public schema shared with the\nsnapshot CLI. It preserves omitted groups and inherited appearance, defaulting\nonly `mode` to `solid`. A file's modes, in order, are `solid`, `shaded`, `parts`,\n`xray`, `hidden-line`, and `wireframe` (`VIEW_MODE_VALUES`); `grid` is a boolean\nswitch every mode draws, off unless said. Appearance, camera, clip and exploded are\nshared. A mode is a look at its own defaults: it has no settings of its own, and\nanything else (a per-mode tree, a style group) fails validation.\n\n```js\n{ mode: \"parts\", grid: true, camera: { projection: \"orthographic\", focalLength: 50 }, clip: { axis: \"z\", at: 4 } }\n```\n\nPreview's photographic look (Render) is no mode: it is asked for on its own, by the\nviewer's Preview and a snapshot's `preview`, over the same display. Its own settings\n(`PreviewSettings`: `parts`, `lighting`, `background`, `floor`) are the viewer's,\nkept beside the display, never in it (`normalizePreviewSettings`,\n`resolvePreviewSettings`, `previewSettingsChanged`); a snapshot draws Preview at its\ndefaults. `readViewSettings` and `readPreviewSettings` read a stored record setting\nby setting, leaving out what this build cannot read, without rewriting it.\n\n`resolveViewSettings` returns the established renderer groups. Solid is shaded with\nthe model's visible CAD edges where the view offers them (`edges`); Shaded is the same\nsurfaces without; Parts is Solid with the by-part palette. X-ray uses 0.22 surface\nopacity and all edges; Hidden line uses depth-only surfaces and visible edges; Wireframe\nhas no fill and all edges, both lighter on a dark backdrop. With `preview: true` it\nresolves Render whatever the mode: perspective, no edges, no grid and the photographic\nstudio, from `previewSettings`. The modes use the shared camera, orthographic by default.\nThe grid is the same measuring grid in every mode (`viewGridDefaults`). The view's\ncapabilities are explicit injected lists (`viewFeaturesForKind`): a STEP every mode, any\nother 3D file Solid, and Parts where it has several parts (`scenePartCount`); a saved mode\na view does not offer resolves as Solid without rewriting storage. Surface opacity\nmultiplies authored opacity; colours remain presentation settings, never model material\nedits.\n\nClip keeps coordinates below its plane by default (`invert: false`); Reverse (in Clip's axis dropdown)\nkeeps coordinates above it. `clip.at` is the plane's coordinate on its axis in millimetres;\nomitting it cuts through the middle of the authored bounds. A disabled clip cuts nothing.\n`clip.partColors` defaults to true. Caps use each part's normal base material color,\nwithout selection/hover tints or opacity; false uses uniform amber (`0xe9a451`).\nCaps stay opaque even for transparent parts, and outer materials are unchanged.\nEach intersected part has a winding pair and an opaque fill, so holes remain empty;\nthe fill (as wide as the model on the plane) writes the stencil back to 0 wherever it\ncovers, so no part needs a stencil clear of the whole buffer after it. Cap geometry/materials are owned by Clip; stencil\ngeometry is borrowed from the display meshes. Generic scene looks preserve\n`mesh.userData.cadBaseColor` before highlight ink; STEP records use `baseColor`\nand normal effect colors. A mesh with several materials uses its first material\nas its cap color; texture and vertex-color variation are not sampled.\n\n`resolveViewSceneSettings({display, preview, previewSettings, camera, appearance, quality})` is the public\nViewer/snapshot scene-policy boundary (`preview`: Render over the display, as above). Its `view` is the full grouped state;\n`display` is the historical internal draw policy, including `surfaces` and\nedge color/visibility. `camera` combines the top-level pose with the grouped\nlens. Public top-level and per-output cameras contain pose/framing only;\nprojection and focal length belong to `display.camera`. Focal length ranges\nfrom 20 to 200 mm. `orthographicHalfHeight` is the positive pre-zoom vertical\nhalf-extent and may be retained while perspective is active.\n\nThe resolver's `render.enabled` means lighting, background or floor needs the\nstudio scene helper. `render.configuration.lighting.enabled` independently\ncontrols photographic illumination and PMREM allocation. `theme` retains the\nneutral workbench recipe whenever photographic lighting is disabled, including\nwhen a custom background or floor is present. Consumers build CAD models with\n`surfaceSettings: resolved.view.surfaces` and the resolved edges; they must not\nreapply historical mode-owned edge forcing. `resolveSceneSettings()` remains an\ninternal compatibility resolver for older low-level consumers.\n\nBackground opacity follows the ordinary alpha convention: 0 is transparent and\n1 is opaque. Fractional alpha is carried by the renderer clear color with\n`scene.background = null`, so viewer checkerboards and PNG captures agree. Over that\nclear the studio draws its backdrop (`studioBackdrop.js`), first of everything and\nwriting no depth: the background color as a seamless sweep, lifted toward white\nbehind the middle of the frame, shaded toward the corners and, by how far the view\nray looks up, above the horizon (`PHOTOGRAPHIC_STUDIO_BACKDROP`; the shade is a larger\nshare of a dark color than of a light one). It writes the clear's alpha, premultiplied\nas three's clear is, so a half-transparent background keeps its gradient; at alpha 0 it\nis not drawn, and a caller that renders with `scene.background` taken away and a clear\nalpha of 0 (a thumbnail) gets nothing of it. The floor's mirrored draw leaves it out.\nThe floor is independent of background alpha. Render's stands at authored\nZ=0 (`placement: \"origin\"`, `RENDER_FLOOR_PLACEMENT`, the Render recipe's backdrop\ndefault too); `placement: \"lowest\"` follows the current model minimum without moving\ngeometry or lighting. Its double-sided plane uses one transparency pass,\nkeeping geometry below the origin visible. Depth fitting includes the actual floor elevation;\northographic views can use a signed near plane when the floor crosses the eye.\n\nLighting quality is `preview` or `final`, mapping to standard/high scene policy.\nThe viewer passes `lightingQuality: \"preview\"`; the CLI baseline is `final`.\nThat environment default also feeds Preview's default comparison and does not pin a saved\noverride. An explicit Preview `lighting.quality` wins in the viewer; a snapshot's `preview`\nis always `final`.\nFinal uses the bounded L3 mesh rung, a 0.25px viewport target, 4096px spotlight\nshadows, a 512px procedural environment and 2x snapshot capture, and a still view\nin the viewer converges over 64 samples of the studio's frame (Preview: 32). Technical\n`quality.tessellation` and `output.renderScale` remain explicit overrides.\nPNG output retains requested dimensions by resampling the full drawing buffer.\n\nThe two studios use one physical Render pipeline. A neutral HDR key card, a rear\nfill card, a side bounce card and the studio's fixed soft sources\n(`PHOTOGRAPHIC_STUDIO_PANELS`: a large overhead softbox and two tall strip boxes\nabout 75 degrees either side of the default camera) hang in a studio sweep and\ngenerate a procedural PMREM for authored PBR reflections; one aligned,\nmodel-scaled SpotLight supplies direct illumination and PCF shadows. Softbox\nsize changes every source's area and bounded shadow softness while keeping each\nsource's total flux stable. Fill sets both fill cards' radiance as a fraction of\nthe key card's. Rotation moves the direct light and `scene.environmentRotation`\ntogether around CAD Z. An overhead side key reveals depth; the rear fill keeps\nhorizontal reflections readable and the bounce, low on the key's far side,\nlifts the faces the key cannot reach (an iso view's right-hand side). The\nsoftbox and strips are brightest along their centres and fall off toward their\nedges, and the sweep (`PHOTOGRAPHIC_STUDIO_ROOM_RADIANCE`: 0.14 overhead, 0.07\nat the horizon and a light floor's 0.3 below, easing between them) is dimmer\nthan they are, so polished and satin metal show defined gradients and highlight\nlines between darker gaps — what reads as metal rather than grey plastic — and\ndark glossy parts keep their color. Nothing is a void: a face turned away from\nevery source keeps a soft fill. Environment radiance and direct illumination\nshare a calibrated zero-EV lighting budget. Khronos PBR Neutral tone mapping is\nfixed; `toneMappingExposure` is `2 ** exposure`. Light and dark differ only in\ndefault backdrop color.\n\n#### The studio's frame\n\nRender's lighting is drawn through the studio's frame (`studioFrame.js`), which\n`applyPhotographicStudio` keeps as `state.frame` while its lighting is on, and the viewer\nand the snapshot both draw the scene through. Three draws the scene as before, but into an\nXR-flagged target of the frame's (`isXRRenderTarget`), which three draws exactly as the\ncanvas, tone mapping and colour space included, so no material compiles a second program;\nits depth is kept. Small full-screen passes then add:\n\n- **Ambient occlusion.** Ground-truth AO (GTAO, after XeGTAO) from that depth alone, at half\n  the drawing buffer's resolution: the horizon either side of each pixel along three screen\n  directions, four steps each, within a radius of 0.1 of the model's rest radius (never more\n  than a fifth of the view's height). Every sample is read at a full-resolution pixel's\n  centre; a depth read from one pixel and placed at another turned sloped faces into\n  staircases of false horizons. A depth-aware blur smooths it, and the composite upsamples it\n  by depth, so it never bleeds across a silhouette, and darkens the scene's colour by it in\n  linear light (`power` 2). The floor writes no depth, so it keeps its own contact shadow;\n  the background is never darkened. This is what reads as a photograph rather than a\n  render: the seam between a cover and its housing, the space between stacked plates, a\n  bolt head on its plate, the inside of a bore.\n- **Convergence.** A still view is the average of `samples` samples, each blended into a\n  half-float accumulation at `1/(n+1)` and presented with a level of dither. Every sample but\n  the first moves the camera a fraction of a pixel (Halton 2, 3), so edges and fine\n  highlights are anti-aliased; turns the occlusion's directions (interleaved gradient noise\n  per sample), so it converges smooth; and moves the key light to a point of its softbox's\n  face (`sampleKey`: R2 over the key card's face, `PHOTOGRAPHIC_STUDIO_SOFT_SHADOW.spread`\n  of it, scaled by Size), so the key's shadows become a softbox's: crisp where a part meets\n  what it shades and soft away from it, on the model and, through the floor shadow's\n  composite, on the floor. A shadow pass that only moved the key holds the floor shadow's\n  heights (`holdHeights`) and re-composites its key shadow alone.\n\nThe frame's caller says whether a frame is `still`: nothing but, perhaps, the camera changed\nsince the last. A still frame is the next sample unless the camera has moved the model by\nmore than `STUDIO_FRAME_DRIFT_PIXELS` (0.25) on screen since the picture's first sample, so the\ntail of a damped orbit still converges; anything else (a camera that moved, a pose, a\nroutine's frame, a setting, a resize) starts again from the first sample, which is a whole\npicture of its own: the key at rest, the occlusion denoised in space. `render` answers whether\nsamples are still owed (`refining`); the caller asks for the next frame while they are, and\nfor none once the picture has converged. A moving view therefore pays for one draw and the\nsmall passes a frame; the samples only while it rests.\n\nMeasured at 3024 × 1964 on an M1 Max over 0.6 M and 3 M triangles: a moving frame\n3.1 → 4.5 ms and 5.0 → 6.6 ms, a sample 5.2 and 7.8 ms, all 32 samples back to back 155\nand 240 ms (in the viewer, a sample a display frame: about half a second). Memory is the scene target (8-bit colour and depth-stencil, multisampled as\nthe canvas is: four samples, two on a canvas of pixel ratio 2 or more), its resolve, a\nhalf-float accumulation and two half-resolution occlusion targets: about 200 MB at that\nsize, released with the studio. Software rendering has no frame: three's single draw, as\nbefore, whose key casts no shadow to soften.\n\n`applyPhotographicStudio(THREE, runtime, configuration, options)` owns the\nsynchronous light, ground and renderer state and updates those objects in place.\n`disposePhotographicStudio(runtime)` releases only those objects, the studio's frame\nincluded (`applyPhotographicStudio`'s `frame`: false for none, or its `samples` and\n`multisample`). The caller\nseparately owns the PMREM returned by\n`createEnvironmentResource(renderer, configuration, {size})` — synchronous GPU\nwork, returned directly rather than as a promise — assigns its\ntexture to `scene.environment`, and releases it through\n`disposeEnvironmentResource()`. `environmentResourceIdentity()` includes\nsoftbox size, fill, and PMREM resolution; it excludes rotation so rotating the\nrig is a live scene update rather than an environment rebuild. A resource also\nexposes `readPixels()` for transferring its half-float PMREM from worker WebGL.\nThe interactive UI prepares and caches these maps off the main thread; snapshot\nrendering can keep the synchronous caller-owned path.\n\nThe floor has no edge (`PHOTOGRAPHIC_STUDIO_FLOOR_FADE`): from 1.6 times the model's\nreach (the larger of its rest radius and its footprint's half-diagonal) it fades out\nto its inscribed circle at 6 times it, where the two-triangle plane ends, so a low or\nwide view shows no horizon line and no corner, only the backdrop. The fade is applied\nto the floor's alpha after its reflection is laid over it, so the reflection fades with\nit; it is squared, so its tail is long even foreshortened at a grazing angle. Camera\nfitting keeps `PHOTOGRAPHIC_STUDIO_STAGE_RADIUS_MULTIPLIER` as far-plane padding, far past\nthat rim. Near-plane fitting includes the visible floor as well as the model.\n\nThe physical floor is mostly self-lit backdrop color and receives no shadow\nmap; its shadow is a contact layer drawn over it (`studioContactShadow.js`):\ndarkest where the model meets the floor, a broad occlusion fading with height,\nand the key's cast shadow crisp near the model and softening into a wide, light\npenumbra away from it. The layer is one texture over a square fitted to the\nrest placement (`PHOTOGRAPHIC_STUDIO_CONTACT_SHADOW`), as deep as the floor is\nopaque, and hidden while the studio's lighting is off; a floor at zero opacity\nis not drawn, so it bakes nothing. It is baked in two steps: the heights, a\ndepth pass of the shadow casters seen up through the floor by a probe light\nthat is never added to the scene, and the composite, three small full-screen\npasses over those heights and the key's own shadow map. Both are due only when\nthe key's shadows re-render: a sentinel in the studio, a shadow caster of zero\narea that every shadow pass and the main pass draw (it rasterizes nothing in\neither), notices that pass. The composite runs in that frame, so the key's cast\nshadow on the floor follows every frame a model moves in. The heights do too,\nunless the caller passes a `heightInterval` (`applyPhotographicStudio`'s\n`contactShadow` option): then a scene that keeps changing re-measures them at\nmost that often, and once more when it stops, in a frame the layer asks for\n(`requestFrame`), so what is shown at rest is exact and only the contact\ndarkening lags while a model moves. The interactive viewer re-renders shadows\nonly for a change that can alter them, never for a camera move or a highlight,\nso it bakes nothing on those frames; the snapshot renderer, which renders\nshadows every frame and passes no interval, bakes both steps every frame. A\nruntime that renders in software (`softwareRendering`) gets no contact layer:\nits key casts no shadow either. A transparent legacy backdrop's floor stays a\n`ShadowMaterial` catcher of the key's shadow.\n\nThe floor and its shadow layer are dithered: each adds up to one 8-bit level of\nnoise as it is drawn (the floor's divided by its alpha, so its blend keeps it\nwhole; the layer's drawn premultiplied, so the blend adds exactly that noise over\nwhatever floor is under it). A dark floor's shading spans only a few levels of\nthe canvas, and rounding drew it as wavy contour bands; the noise is too fine to\nsee and leaves every average colour where it was. A snapshot drawn at a render\nscale above 1 averages that scale squared of drawn pixels into each one it keeps,\nwhich quietened the noise to a fraction of a level and let the rounding band\nagain: it passes its render scale as `applyPhotographicStudio`'s `ditherScale`,\nwhich spreads both noises that much wider (`syncDitherScale`). The viewer keeps\n1, and at 1 nothing is defined: its shaders are the ones they always were.\n\nThe floor's finish is `backdrop.groundFinish` (`PHOTOGRAPHIC_STUDIO_FLOOR_FINISHES`):\n`matte`, the default, or `glossy`, a glossier surface that also reflects the model\n(`studioFloorReflection.js`). Before each frame of the scene (its `onBeforeRender`)\nthe scene is drawn again from the camera mirrored in the floor, at half the\ncanvas's resolution; three small passes fade and soften it with each reflected\npoint's height (crisp where the model meets the floor), none of them sampling the\ntarget it draws into, a draw WebGL refuses, and the floor lays it over\nitself with a Fresnel weight, in display colour, so a light floor shows it as a\ndark one does. The mirrored draw runs when the camera moved or the frame re-renders\nshadows (it renders them, and the frame keeps them); a frame for a highlight keeps\nthe last reflection and catches up within 400 ms; a snapshot draws it every frame.\nIt runs before the frame, at the frame's own render depth and into a target three\ntreats as the canvas (`isXRRenderTarget`): drawn nested in the frame, or as an\nordinary target, it made every lit material rebuild its program key twice a frame,\nwhich cost more than the draw. A matte floor allocates none of it; turning the\nfloor matte, the studio's lighting off or the floor off releases it.\n\nEnvironment radiance and direct illumination are calibrated together at zero EV\nacross colored assemblies, gray mechanical models, and authored metal/plastic\nfinishes.\n\n#### What Render's public contract does NOT have\n\nNo global material, no color grading, no arbitrary lights, no floor physics, no\nglow controls. Adding one of these is a change to the law in the package\nREADME, not a new option here.\n\nMaterials reaching that scene stay authored. STEP package material channels are\ninputs to the rig: assigned sparse materials use roughness 0.42, metalness 0.03,\nclearcoat 0, clearcoat roughness 0.26, and opacity 1; an absent base color\nretains the STEP color, and authored opacity multiplies its source alpha.\nA GLB, a mesh file and a robot reach the studio as the scene their viewer renderer\nbuilds. A GLB is its native glTF hierarchy, so its textures and PBR channels stay\nattached and Render wears them exactly as authored; an STL, a 3MF and a robot author no\nfinish, so they wear the studio's surface over their colours (a 3MF's object colours, a\nrobot description's colour, else its link mesh's own).\n\nSnapshots use the same grouped display contract. Photographic lighting supports\nonly the `view` capture mode. Camera,\nselection, clipping, exploded view, guides, part colour, animation, video,\nkinematics, robot joints, tessellation quality, per-output cameras and output\nsizing remain composable.\n\nPhotographic lighting creates its WebGL renderer with\n`logarithmicDepthBuffer: false`. Three's logarithmic depth shader path does not\nproduce usable contact shadows. Render callers fit ordinary-depth near/far\nplanes to current model bounds with `fitCameraDepthToBounds(camera, bounds)`.\nThe interactive viewer uses this fitted conventional depth for every preset so\nsettings can enable shadows without replacing its renderer/canvas. The fit also\nuses visible records for closeups, the floor's actual elevation, and the grid's\nplane and bounds independently of Floor. Otherwise the subject's near plane\nclips foreground guides, or the far plane truncates their finite span. A\nperspective camera passes its `pivot` (the point it looks at): its near plane\nnever comes nearer than 1/256 of the pivot's depth, so a closeup the fit cannot\nmeasure (the camera inside a part's own box, or a routine deforming what it\nmeasures) keeps its depth resolution instead of making close surfaces fight.\nSnapshots draw every preset the same way: ordinary depth, fitted to each output\nwith the same inputs (its placed records, the studio floor's elevation, the\ndrawn grid's span and the camera's target as pivot). A logarithmic buffer would\nalso lose the instanced CAD edges, which write no logarithmic depth: under a\nperspective camera every edge failed its depth test against the surfaces.\n\n### `common/source.js`\n\n```js\nimport {\n  loadSource,\n  stepParameterRuntime\n} from \"@text-to-cad/core/common/source.js\";\n```\n\n`loadSource(input, options)` returns a normalized STEP render source:\n\n```js\n{\n  kind,\n  meshData,\n  selectorRuntime,\n  displayEdgeRuntime,\n  stepParameterSource,\n  resolved,\n  url,\n  glbUrl,\n  cadPath\n}\n```\n\nAccepted input fields:\n\n- `kind`: `step` or `stp`, or inferred from a URL. A GLB, STL, 3MF, URDF, SRDF\n  or SDF is refused by name: it is drawn by its own scene builder\n  (`common/headlessScene.js`), never flattened into mesh data here.\n- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.\n- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.\n- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.\n- A caller that passes a `resolved` packet together with any source URL must also pass\n  `resolved.inputPath`. Render asset caches are page-lifetime, so a resolved job has to name the\n  source its cache entries belong to; a resolved job without `inputPath` is rejected rather than\n  cached under an unidentified source. Callers with no `resolved` packet (the interactive viewer and\n  the docs hero renderer) render one source per page and need nothing.\n- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller\n  already owns sidecar loading.\n- `kinematics`: pose values for the model's kinematics — a declared preset name,\n  or `{dof: value}`. Same spelling as the `--kinematics` flag, the snapshot job\n  key and the sidecar section.\n- `stepParameterUrl` or `resolved.stepParameterUrl`: model sidecar\n  (`.step.json`) URL, whose `kinematics` section is compiled here.\n- `quality.tessellation`: explicit STEP tolerances in every mode. When\n  omitted, `preview: true` selects the bounded final mesh rung.\n\nSTEP-only options are rejected for non-STEP sources. The old shared `params`\nfield is rejected, and so is the retired `stepParameters` spelling; use\n`kinematics`.\n\nUse `stepParameterRuntime(stepParameterSource)` to turn the loaded parameter\nsource into the runtime object `buildModel` accepts.\n\n### `common/cadScene.js`\n\n```js\nimport {\n  buildModel,\n  fitCameraToModel\n} from \"@text-to-cad/core/common/cadScene.js\";\n```\n\n`buildModel(THREE, source, settings)` returns a model API:\n\n```js\n{\n  source,\n  meshData,\n  root,\n  modelGroup,\n  edgesGroup,\n  displayRecords,\n  records,\n  bounds,\n  restBounds,\n  radius,\n  runtime,\n  update(nextSettings),\n  dispose()\n}\n```\n\n`source` can be a `loadSource()` result or raw mesh data. The model owns the\nThree.js object graph and its mutable state.\n\n`bounds` follows the live pose — what lighting, the floor, shadows and clipping\nneed. `restBounds` is the same model at its ZERO pose, before a parameter, mate\nor animation frame moved a record, and it is what a camera fit is grounded on so\nthat posing a model never re-frames it. A package that declares its whole box\n(assembly.json's `bbox`, `declaredBounds` in its composition) rests in that box,\nas the scene contract has it: the viewer frames and grounds it, and a snapshot\nsizes its ground and radiates an exploded view from it. A family scene carries\nits own (`restBounds` in the scene contract): a robot's is every joint at its\ndefault.\n\nCommon settings:\n\n- `theme`: normalized or raw CAD scene settings. Render passes none and supplies\n  `materialSettings` instead.\n- `displayMode`: `shaded`, `shaded_edges`, `transparent`, `hidden_edges`,\n  `hidden_lines_removed`, `unshaded`, or `wireframe`.\n- `edgeSettings`: display-owned CAD edge style. Themes do not own edges.\n- `materialOverrides`: sparse explicit PBR overrides; authored PBR otherwise\n  wins over studio material fallbacks.\n- `scale`/`sceneScale`: CAD or robot scene scale.\n- `selection`: internal selection/filtering state. `focus` and `hide`\n  filter rendered parts before records are built. Viewer-only fields such as\n  `selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.\n- `clip`: normalized clip-plane settings.\n- `stepParameters`: compiled kinematics runtime object, from\n  `stepParameterRuntime()`.\n- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.\n- `renderPartsIndividually`: build per-part records instead of a whole mesh.\n- `edgeRendering`: declarative edge rendering configuration.\n\nDeclarative screen-space edge rendering:\n\n```js\nbuildModel(THREE, source, {\n  edgeRendering: {\n    mode: \"screen-space\",\n    Line2,\n    LineGeometry,\n    LineSegments2,\n    LineSegmentsGeometry,\n    LineMaterial,\n    wireframeEdgeColor: \"#111827\"\n  }\n});\n```\n\nThe model keeps screen-space line material bookkeeping internal through\n`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.\nCallers should not provide callbacks that create edge objects.\n\n`model.update(nextSettings)` merges mutable settings, rebuilds geometry only\nwhen needed, reapplies material/selection/clip/STEP parameter state, and returns\nthe same model API. `model.dispose()` releases model-owned scene objects and\nSTEP parameter cleanup hooks.\n\nSource colors: a GLB's material base colors and its `COLOR_0` vertex attribute\nboth count as source colors (`lib/render/glbMeshData.js`). A vertex-colored\npart renders on a white base so the ramp shows unmixed — an FEA result or scan\nheatmap keeps its colors even when the file declares no materials at all — and\n`overrideSourceColors` in material settings replaces both kinds with display\nfills.\nPackage components carry no vertex colors; their coloring is the descriptor's\noccurrence/component/face colors.\n\n`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic\ncamera framing helper used by interactive rendering.\n\n### Display modes, CAD edges and geometry sharing\n\nInternal draw modes are `shaded`, `shaded_edges`, `transparent`,\n`hidden_edges`, `hidden_lines_removed`, `unshaded`, and `wireframe`. Public\npresets resolve to those implementation details plus independent surface and\nedge settings; never feed a public preset into `normalizeDisplayMode()`. Normal CAD\nkeeps authored albedo, opacity and finish. Workbench PBR channels are fallbacks;\na small neutral reflection environment makes authored metals readable without\nloading the photographic studio.\n`resolveDisplayMaterialSettings()` applies the shared Original, Single color\nand Color by part policy without app state.\n\n**Scene geometry is the tessellator's INDEXED output.** A surf component's\n`meshData` shares the tessellation's vertex, normal and index buffers by\nreference (a decoded `.tess` cache entry is copied out of its one entry\nbuffer) and is never expanded per triangle corner.\n\n**CAD edges are not a surface shader.** `surfMeshData.js` emits indexed line\nsegments (`cadEdgePositions` + `cadEdgeIndices` + `cadEdgeClassRanges`, from\nthe same tessellation's boundary polylines, ~1.5 bytes per surface triangle)\nand `cadScene.js` draws them as ONE instanced screen-space line draw per\ncomponent (`cadEdgeInstances.js`): the instances are every (segment,\noccurrence) pair, decoded in the vertex shader from a per-component segment\ntexture (32 B per drawn segment, cached on the component) and a per-set\ninstance texture (128 B per occurrence: matrix, colour, opacity, visibility,\nhighlight).\n\n**Every mode draws the same edges, Wireframe included.** Wireframe is the CAD\nedges through the model with no fill, never the tessellation's triangles: a\nswitch to it reuses the segment textures (or, for a baked mesh without segments,\nthe one cached edge geometry per component, keyed by no mode) that Solid, X-ray\nand Hidden line draw, so it builds nothing per triangle. It once built three's\n`WireframeGeometry` over every triangle on the main thread: 10 s and +1 GB on a\n437 MB STEP, for a picture that read as a solid blob.\n\n`cadInk.js` fixes one dark model-edge palette and nominal widths per class:\nfeature 1, tangent 0.65, seam 0.8, and degenerate 0 (hidden). Public\n`display.edges` exposes enabled, visible/all visibility and color; widths stay\nrenderer-owned and the default color preserves the restrained class palette. Grid settings keep\n`enabled` plus optional `color`, `opacity` and `density`; omission follows the\nappearance's graph-paper ink (`GRID_LINE_DEFAULTS` in `stageGrid.js`), while an\nexplicit color applies to every minor and major line but never the red X and\ngreen Y axes. Viewer and snapshots draw the same grid (`createGridMesh`): its\nstep, majors and long distance fade are in `packages/ui/docs/render-mode.md`.\nThe Axes line is the blue Z axis.\nAppearance updates preserve model lighting, materials, geometry,\nsegment textures and occurrence slots; only the canvas and guides adapt.\nAn edge color edit keeps them too: `model.update` recolors the live edge draws\nin place.\n\nA thickness is a FULL width in DEVICE pixels — every line shader normalises its\nextrusion by the drawing buffer, never the CSS size, and the fragment stage\nfilters a box with a symmetric ±0.75 px kernel. Integrated coverage equals the\nnominal width even for subpixel lines, and zero width has zero coverage. Both\nline paths share the filter; antialiasing does not inflate thin lines or depend\non the framebuffer sample count.\n\nPer-occurrence highlight, dim, hide, focus, exploded placement and selection\nare slots in that texture, written by the same record passes\n(`applyDisplayRecordTransform`, `applyPartVisualState`,\n`syncRecordEdgeMaterials`) that drive a plain line object; highlighted\noccurrences draw in a second pass at the highlight render order. A deformed\ntube leaves its slot for private screen-space lines, one per drawn class, that\nbend with the surface and preserve the same class weights and colours.\nBasic-only hosts use separate per-class materials too, so appearance changes\nretain private edge geometry and cannot recolour another scene's component.\nGPU cost per component: two textures, one 4-vertex quad, two materials, one\ndraw call (+1 while any occurrence is highlighted).\n\n**One upload per component.** Geometry built from a shared component is cached\non the component object (`part.sourceMesh`), never on the composed package\n`meshData`: a package is re-composed on every progressive publish and LOD swap,\nand every occurrence, publish and swap reuses the one upload. A publish of the\nsame model reaches the live scene through `api.update({ source })`, which\nreconciles records by occurrence id — records already on screen keep their\nmesh, materials, visual and deformation state and BVH; only new occurrences are\nbuilt and only departed ones disposed (ownership and disposal:\n[resource-ownership.md](resource-ownership.md)). Effects that change vertex\npositions or normals acquire writable attributes before deforming them;\nmaterial refreshes leave component data unchanged. This keeps large assemblies\nfrom duplicating these buffers for display. Assemblies keep geometry in their\ncomponent buffers; they allocate no combined copy of all positions, normals and\nindices. Rendering visits the placed components directly, and\nthe Viewer accepts that component geometry.\n\n### `common/renderModel.js`\n\nUse this module for interactive browser canvases, including the docs hero.\n\n```js\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n```\n\n`renderModel(THREE, model, options)` returns an interactive viewport API:\n\n```js\n{\n  THREE,\n  model,\n  renderer,\n  scene,\n  camera,\n  ready,\n  resize(),\n  render(),\n  start(),\n  stop(),\n  capturePng(),\n  dispose()\n}\n```\n\nCommon options:\n\n- `canvas`: existing canvas for the renderer.\n- `hostElement`/`container`: element used for responsive sizing.\n- `renderer`: caller-owned renderer. If omitted, one is created.\n- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are\n  created.\n- `theme`/`themeSettings`: background and lighting settings.\n- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,\n  `logarithmicDepthBuffer`, `shadows`: renderer controls.\n- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.\n- `pixelRatio`, `maxPixelRatio`: output density controls.\n- `autoResize`: set `false` to disable `ResizeObserver`.\n- `autoStart`: set `true` to start an animation loop.\n- `autoRender`: set `false` to prevent the initial render.\n- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.\n- `disposeModel`: set `false` when the caller will dispose the model.\n\n`dispose()` stops animation, disconnects resize observation, removes the model\nroot from the scene, and disposes the created renderer/model unless ownership\nwas explicitly retained by options.\n\n### `common/renderMeshScene.js`\n\nUse this module for deterministic headless snapshot rendering.\n\n```js\nimport {\n  renderJobContext,\n  modelOptionsForRenderJob,\n  renderModel,\n  captureModel,\n  renderMeshJob\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n```\n\n`renderJobContext(meshData, job)` resolves the shared scene contract plus\nsnapshot-owned output policy: display, camera, quality, scene scale, outputs,\nSTEP topology edge visibility, and warnings. Studio scene quality is Preview's final\nwhen the job asks for `preview`; `job.quality` contains technical tessellation only. A\nfamily scene passes `{ bounds, partCount, explodeParts }` for `meshData`; its job's kind\nresolves the view features (`viewFeaturesForKind`): anything but a STEP has no edges,\nParts only with several parts, and Explode only for a GLB of several.\n\n`modelOptionsForRenderJob(context, job)` converts that policy into\n`buildModel()` settings.\n\nSnapshot `renderModel(THREE, model, { job, context })` holds either a CAD model from\n`buildModel` or a family scene wrapped by `headlessSceneModel`; only a CAD model has a\nruntime, so only it takes the exploded view, topology edges and screen-space line\nwidths. It returns a headless viewport:\n\n```js\n{\n  THREE,\n  model,\n  scene,\n  renderer,\n  orthographicCamera,\n  perspectiveCamera,\n  context,\n  sceneBuildStarted,\n  ready,\n  dispose()\n}\n```\n\nThis `renderModel` is intentionally separate from `common/renderModel.js`.\nRender is drawn through the studio's frame, every sample of it at once\n(`renderStill`: `PHOTOGRAPHIC_STUDIO_FRAME.stillSamples`, 32, re-rendering shadows only for a\nsample that moved the key), with no MSAA of its own: the snapshot supersamples at its render\nscale. A video's frame and a software renderer draw one sample (`snapshotStudioSamples`).\nIt uses snapshot sizing, studio environment, the shared stage floor/glow/shadow,\nmodel-scaled lights with a fitted shadow frustum, canonical camera projection,\nand deterministic renderer settings. Automatic perspective cameras fit the\ncurrent visible vertices to `output.padding` when `output.tightFrame` is true;\notherwise they fit the model bounds. An explicit camera position is never\nreframed. Video capture fits its precomputed sequence-union bounds once so the\ncamera does not breathe between frames. As in the viewer, a still's studio floor\nand its contact shadow stay sized and centred on the rest placement however the\nmodel is posed or exploded (`groundBounds`), and an exploded view radiates from\nthat rest box.\n\n`captureModel(viewport, { job })` returns data only:\n\n- `mode: \"view\"`: PNG data URLs in `outputs`.\n- `mode: \"list\"`: part list and bounds. A CAD model lists its composed part\n  occurrences; a family scene lists what it drew, one row per mesh, in its scene\n  graph's order (a robot's scene attaches a link's meshes before the joints it\n  carries, so its rows run down the tree from the root).\n\nIt does not write files. The CAD skill snapshot CLI writes the returned data to\ndisk. Consumers use compiled `@text-to-cad/core` exports; generated snapshot browser assets\nbundle this entrypoint into cadgen's packaged runtime (`cadgen/_runtime/browser`).\n\n`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,\nbuilds a model, renders/captures it, and disposes owned resources.\n\n### `common/headlessScene.js`\n\nThe snapshot page's half of \"one scene builder per file family\". `headlessSceneFamily(job)`\nanswers, from the job's resolved kind, the family whose loader and builder draw it (GLB;\nSTL and 3MF; URDF, SRDF and SDF), or null for a STEP document. A family is\n`{ load(job, { resources }), build(THREE, loaded, job), release(loaded) }`, each a call to\nthe module the viewer's renderer calls: `loadRenderGlbDocument` + `createGlbScene`,\n`loadRenderMeshByUrl` + `buildMeshScene`, `loadRobot` + `createRobotScene`. A robot is\nposed as it is built, at `robotOpeningPose` (every joint's default, then an SRDF's `home`\ngroup state: where the viewer opens it) with the job's `jointValues` on top.\n\n`headlessSceneModel(THREE, scene, headlessSceneDress(context.sceneSettings))` dresses the\nscene exactly as the viewer's viewport dresses one it adopts, with the look\n`resolveSceneSurfaceLook` resolves (`common/sceneSettings.js`, which the viewport calls\ntoo) and shadows while the lighting is on, and exposes what `renderModel` and\n`captureModel` read of any model: `root`, `bounds`, `restBounds` (what sizes the ground),\n`keepsAuthoredFinish`, the drawn meshes for the tight frame, `listParts()` and `dispose()`.\nThe still's tight frame reads a skinned or morphed mesh where the GPU draws it.\n\n## Kinematics\n\nTwo names, two things, and they are not interchangeable:\n\n* `kinematics` is the POSE INPUT — what `loadSource()` and the snapshot job\n  packet take. A declared preset name, or direct DOF values:\n\n  ```json\n  { \"drive\": 180, \"ringVisible\": false }\n  ```\n\n  Animation envelopes (`animate`, `fps`, `durationSeconds`, `duration`, `loop`)\n  are retired and throw: a still renders one frame at the given values.\n\n* `stepParameters` is the compiled RUNTIME OBJECT that `buildModel()` takes,\n  produced by `stepParameterRuntime(source.stepParameterSource)`.\n\n`common/stepParameters.js` validates the pose values against the loaded\ndefinition and normalizes defaults.\n`loadSource()` uses it to populate `source.stepParameterSource`; callers then\npass `stepParameterRuntime()` into `buildModel()`.\n\n## Examples\n\nInteractive viewer/docs usage:\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource, stepParameterRuntime } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n\nconst source = await loadSource({\n  kind: \"step\",\n  glbUrl: \"/models/.part.step.glb\",\n  sourceSidecarUrl: \"/models/part.step.json\",\n  cadPath: \"models/part.step\",\n  kinematics: { drive: 180 }\n});\n\nconst model = buildModel(THREE, source, {\n  theme,\n  displayMode: \"shaded_edges\",\n  edgeSettings,\n  stepParameters: stepParameterRuntime(source.stepParameterSource)\n});\n\nconst viewport = renderModel(THREE, model, {\n  canvas,\n  hostElement: canvas.parentElement,\n  theme,\n  autoStart: true\n});\n```\n\nHeadless snapshot usage (a STEP document; `headlessRenderEntry.js` routes every other\nfamily through `common/headlessScene.js` into the same `renderModel`/`captureModel`):\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport {\n  captureModel,\n  modelOptionsForRenderJob,\n  renderJobContext,\n  renderModel\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n\nconst source = await loadSource(job);\nconst context = renderJobContext(source.meshData, job);\nconst model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));\nconst viewport = renderModel(THREE, model, { job, context });\n\ntry {\n  const result = await captureModel(viewport, { job });\n  // Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.\n} finally {\n  viewport.dispose();\n}\n```\n\n## Ownership Rules\n\n- Do not write files from shared render APIs. Return data to the owning CLI or\n  application layer.\n- Do not expose object-construction callbacks for edges. Use declarative\n  `edgeRendering`.\n- Keep STEP-only options explicitly STEP-named and reject them for non-STEP\n  sources.\n- Dispose viewports and models that you create.\n- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new\n  shared render code instead of loading assets or constructing render scenes\n  inline.\n- Draw a GLB, a mesh file or a robot with its family's scene builder, in the viewer\n  and in a snapshot alike. Never give one host its own way to draw a family:\n  `scripts/test/check-kit-boundaries.mjs` (`checkSharedSceneBuilders`) refuses it.\n","packages/core/docs/resource-ownership.md":"# Resource ownership and reuse\n\nHow the shared render code owns, shares, reuses and releases the expensive\nthings: component geometry, GPU buffers, BVH accelerators, tessellation\nworkers and cache reservations. Workspace resource reads and their cache\ngenerations are specified in [workspace services](workspace-resources.md).\n\nThe one-sentence laws are in [the package README](../README.md#the-laws-that-live-here);\nthis file is the mechanism each of them constrains. Nothing here may be traded\naway for speed: **reuse is an optimization, never a change in exact geometry,\npersistent cache identity or occurrence identity.**\n\n| § | Covers |\n|---|---|\n| [1](#1-shared-ownership-and-disposal) | More than one scene owner, and who disposes |\n| [2](#2-the-demand-boundary) | Selectors and raycast accelerators built only when asked for |\n| [3](#3-reuse-without-changing-geometry) | Recomposition, instancing, culling, material pass keys |\n| [4](#4-tessellation-workers) | One request per worker, pool growth, reclamation, charges |\n| [5](#5-browser-mesh-cache-admission) | Probe, admit, verify, adopt — and strict reads |\n\n## 1. Shared ownership and disposal\n\nComponent geometry and edge textures can have more than one scene owner; only\nthe last release disposes shared GPU/BVH state.\n\n- Full scene disposal includes host-reparented groups and records attached by\n  an interrupted reconciliation.\n- Cleanup retires completed ownership steps. A thrown disposal remains\n  retryable and cannot release another scene's share twice.\n- Failed initial construction cleans its partial scene before throwing, or\n  transfers the still-owned scene with an explicit cleanup error for retry.\n- Disposable resource admission never changes exact geometry or persistent\n  cache identity.\n- A scene's sibling (`createSibling()` of `lib/render/glbScene.js`,\n  `lib/render/meshScene.js`, `lib/urdf/robotScene.js`) draws over the source's\n  geometry through `lib/render/sharedGeometry.js`: `shareGeometry` gives it the\n  source's index and attribute objects, so one renderer drawing both scenes uploads\n  each buffer once, and copies only what the sibling rewrites for itself (a robot's\n  vertex colours). `releaseGeometry` lets go of the shared attributes before\n  disposing, so only what the sibling owns is freed. The source stays the owner and\n  is disposed after its siblings. A second STEP scene filled from the same published\n  mesh shares component geometry through the owner counts above.\n- `createCadWebGlRenderer` (`common/webglRenderer.js`) takes the `canvas` it draws\n  on (an `OffscreenCanvas`), so one renderer, and one upload of each resource, can\n  serve several views; disposing it and losing its context are its caller's.\n\n## 2. The demand boundary\n\nRender-only loads do not construct selector topology until it is requested,\nand viewport refinement keeps that boundary.\n\n- Unused components replace only display arrays. A component with active\n  topology replaces its selectors at the same concrete tessellation before\n  publishing new triangles.\n- Raycast accelerators may be deferred until a ray reaches a component's\n  local bounds. The first ray uses exact stock intersection; surface builds\n  enter a single worker queue in idle time and are shared by occurrences.\n- Admission covers private position/index copies, worker scratch, and the\n  result before creating an isolate. Display arrays are never transferred.\n  Each worker ends with its reservation; only serialized BVH nodes and the\n  indirect triangle permutation return. Byte accounting includes both the\n  packed BVH nodes and that indirect permutation.\n- Failed or pending builds keep stock picking. A result must match the\n  geometry's attributes, arrays, versions, groups, draw range and live\n  ownership; the last geometry release cancels queued or active work.\n- Deformation runs before the bounds test.\n- The merged edge-pick `LineSegments` (`edgePickRaycast.js`) keeps its one\n  buffer and gains a table of boxes over contiguous 32-segment runs (and\n  1,024-segment groups), built on the first edge ray (O(segments), a tenth of\n  the index buffer) and keyed by the proxy's position array. A ray runs\n  three's own segment test over only the segments whose boxes (group, run,\n  then the segment's own) it passes within the pick threshold of, widened by\n  1e-4 of the ray's reach, far above three's rounding, so the hits and their\n  order are the full scan's. A sync whose proxy only grew (every earlier\n  index and position bit-identical) keeps the built boxes and boxes only the\n  appended segments; any other change builds afresh on the next edge ray.\n\n## 3. Reuse without changing geometry\n\n### Recomposition\n\nRecomposition may take its previous result when the descriptor and component\ninputs are immutable. It shares unchanged occurrence rows and unchanged tree\nmetadata across detail swaps; changed triangle ranges, bounds, placements and\nappearance still produce the corresponding new records. Its private weak\nownership metadata never enters saved geometry or cache identity. Complete\nsame-file revisions can also seed from the prior composition.\n\nDetached occurrence snapshots detect in-place descriptor or material edits;\nonly rows proven to belong to the current composition may skip scene work. A\nstatic delta requires the same last-applied render context and no dynamic\npose, animation or clip. Changed components update their own records, while\nunchanged occurrence geometry and selectors retain their owners. Arbitrary\nmutable caller meshes keep normal reconciliation.\n\n### Selector composition\n\nAn assembly's selector runtime is composed from one part per placed occurrence\n(`buildSelectorCompositionPart`, `createSelectorRuntimeComposer` in\n`lib/selectors/runtime.js`). A part keeps its rows, its references as last\nplaced and the bundle's own proxy buffers with the placement transform — never\na placed copy of the proxy or lookup maps. The composer re-places a part's\nreferences only when its offsets move, and a composition that begins with the\nprevious one extends that result instead of rebuilding it. The result is value\nfor value the whole-runtime `composeSelectorRuntimes` result for the same\ncomponents in the same order (`runtimeComposer.test.js`). An edge's chain\nendpoints are read lazily from the bundle's unplaced positions, placed and\nrounded as the placed proxy stores them; copies of a reference carry the\naccessor rather than reading it.\n\n### Instancing and culling\n\nRepeated compatible opaque surfaces share instanced draws and retain\noccurrence identity; mirrors, transparency and deformation use explicit\nfallback paths.\n\nDetail publications retain compatible surface instance sets and their original\noccurrence slots. Only changed membership or render passes replace those sets;\nselected, hidden and deformed occurrences keep inactive slots until eligible\nagain. Transform passes reuse each mesh's matrix while observing mutable\nsource transforms and effect matrices on every update.\n\nSurface instance groups use aggregate frustum bounds, invalidated by instance\nmatrix and slot changes. Transformed component boxes and conservative parent\naffine padding keep boundary-crossing geometry eligible, including shear.\nCulling changes draw submission only; occurrence slots and picking remain\nintact. Screen-space edge instances retain their separate drawing policy.\n\n### Material pass keys\n\nMaterial pass keys reuse serialized strings only after comparing their current\nscalar values, emission state, render order and clipping planes. Direct\nmaterial and plane mutations remain observable; custom values use ordinary\nserialization. Reapplying material settings preserves unchanged shader programs\nand owned emissive colors. Colour and PBR uniforms still update on every pass;\nfeature changes, vertex-colour mode and transparency invalidate the appropriate\nprogram. Reflection intensity belongs to the shared pass key. A distinct\nnonblack emissive channel, or emission over vertex colours, uses the ordinary\nmaterial so the instance colour cannot tint that channel.\n\nClients that apply pose, selection or material changes directly to display\nrecords finish with `scene.syncSurfaceInstances()`; this uses the same\nreconciliation as a source update. Clip-only viewer updates also synchronize\nthe shared surface material.\n\n### Revision reuse\n\nCanonical geometry trees contain no surface-producer selection. A runtime view\nbinds each component's opaque surface input to a concrete immutable SURF\nobject, and render/selector reuse requires that exact binding plus the lossless\ntolerance pair and payload version. Other snapshot source scopes remain\nisolated. Placement and appearance belong to each tree's occurrence\ncomposition. Viewport L0 is explicitly coarse; L1 preserves the canonical\ndefault mesh options and key. **Changing viewport detail never changes export\ndefaults.**\n\n## 4. Tessellation workers\n\nEach tessellation worker runs one request at a time; excess requests wait on\nthe client. Render sessions lease that shared scheduler. Releasing one owner\nnever cancels another owner; the final release retires remaining work and\nworkers. Idle pressure reclamation preserves active and queued consumers.\n\n- Aborting synchronous work replaces only its worker, preserving other\n  callers. A failed worker request reports an error instead of retrying\n  expensive tessellation on the UI thread. Inline execution is reserved for\n  environments where workers cannot start.\n- A pool starts with one isolate and grows only for ready concurrent requests,\n  or when a package load finds its first components cached: it starts the\n  isolates its lanes will decode on (`prewarmSurfWorkerPool`, at most the\n  lanes' count), so their start overlaps the batch read of those bodies instead\n  of following it. Sequential viewport refinement reuses that isolate until the\n  drain becomes idle; it does not create a maximum-size pool for each component.\n- Pressure reclamation may release idle worker slots while active and queued\n  consumers keep their work. Each live slot retains its own highest completed\n  request estimate, including handled failures; reclamation or replacement\n  removes that slot's charge.\n- Memory estimates stay on the client. They do not enter worker messages or\n  cache keys, and RAM hits add no worker charge.\n\n## 5. Browser mesh-cache admission\n\nBrowser mesh-cache reads start with a bounded metadata probe. The client\nadmits the encoded object and conservative decoded size before fetching a\nbody, binds that fetch to the probed object digest and byte limit, then\nverifies the v4 header and content address before adoption.\n\nA package's open reads its cache in groups, as a snapshot does: one probe for a\nchunk of components and one TESB read for a batch of their bodies, each\ngrowing from the loader's first publish (eight components) to the server's\nbounds (256 keys, 32 MiB), in load order (`packageBatchReads.js`). A client\nwhose transport caps one reply declares a lower ceiling for a batch's bytes\n(`createCadClient({ maxBatchBytes })`, which its cache reports as\n`batchMaxBytes`): the CAD app's tunnel declares 4 MiB, the most one of its\nreplies carries. A ceiling never raises the server's bound\n(`tessBatchMaxBytes`), and a body larger than the ceiling is read alone, as\nevery body once was; that transport carries it a range at a time and hands the\nprovider the whole, which verifies its digest as it does any body's. A batch's\nframed bytes are charged to the Viewer envelope before it is read and released\nonce its last component has taken its body; each component's decode is still\nadmitted on its own before it runs, and an entry the batch could not read or\nverify is that component's strict-read miss alone. The cold components'\nsurfaces resolve up to 64 to a `/__cad/surfaces` request, each the moment its\nown row is ready.\n\nA validated warm entry carries the full surface-object provenance, so\nrendering does not need the SURF object or its derivation index to remain\npresent. `createHttpTessellationCacheProvider` takes an `origin` for hosts\nwhose cache is not on the page's own origin. TESB body groups remain bounded\nat 32 MiB; the Node export provider uses the same immutable `objects/` and\n`index/mesh/` layout as Python.\n\nA caller admitted using a cache probe can request a strict read: a missing or\ninvalid body reports a typed cache miss before tessellation starts. The viewer\nreleases that reservation and probes another cached tier or resolves the exact\nsurface under fresh cold-work admission. **Cache loss never silently turns a\ncheap decoded-mesh request into unbudgeted surface tessellation.**\n\nA client lazily owns one `createTessellationCache({provider})`, whose\n`createSession({signal})` method lends cancellable views to render sessions.\nNo module-global provider can be swapped by another client. Closing a view aborts\nits reads and prevents late results from enqueueing writes; writes already\nadmitted to the client's byte-bounded queue survive file switches. Closing the\nclient disposes the owner, aborts every view and discards remaining queued\nwrites. Viewer write-backs drain in batches with bounded concurrency: after a\nquiet interval, no later than two seconds after a batch's first entry however\nbusy the load, and at once when a batch reaches its byte bound; an entry is\nturned away only while the writer is still busy with the full batch before it.\nSo the queue holds at most two batches of encoded bodies: the one being\nwritten, at most 32 MiB (`TESS_BATCH_MAX_BYTES`, the default `maxPendingBytes`)\nplus the entry that reached the bound, and the one being collected meanwhile,\nat most 32 MiB: about 64 MiB plus one entry in all (`memoryStats()`).\nSnapshot jobs flush and dispose their own cache after their complete source is\nloaded. Decoded component meshes retain their existing page-wide\ncontent-addressed LRU; a cache view does not retain an additional geometry copy.\n\nImported STEP products without faces keep their occurrence identity. A SURF\nthat holds only wires has no loops to measure, so its scale and box come from\nits edge curves. A SURF with no faces or edges tessellates to empty arrays, a\nzero-size box at the origin and a positive minimum scale. Either way the same\nv4 validation applies. That box is only cache metadata: only triangles are\ndrawn, so composition gives an occurrence without them no bounds, and it\ncannot change the assembly's framing or hide the real parts.\n","packages/core/docs/tube-deformation.md":"# Flexible tube animation\n\n`m.get(target).deformTube({rest, path, twistDeg=0, maxSegmentLength=1, braid?})`\nanimates a continuous tube or hollow sheath already present as a swept STEP\nbody. It deforms the original surface and its CAD edge lines in the same shared\npass used by CAD Viewer and snapshots. It does not create a replacement rope or\nchange the STEP artifact. Subsequent rigid occurrence transforms act on the\ndeformed result. The paths use assembly coordinates before those transforms.\n\n```js\nconst rest = {\n  normal: [0, 0, 1],\n  segments: [{kind: 'line', start: [0, 0, 0], end: [10, 0, 0]}]\n};\nconst bent = {\n  normal: [0, 0, 1],\n  segments: [{\n    kind: 'arc', center: [0, 5, 0], axis: [0, 0, 1],\n    start: [0, 0, 0], sweepDeg: 90\n  }]\n};\n// Inside an embedded animation clip update(t, m):\nm.get('tendon').deformTube({rest, path: bent, twistDeg: 360 * t});\n```\n\nEach path is `{normal, segments}`. `normal` is REQUIRED on both the rest and\nthe posed path: it seeds the transverse frame at the first point (projected\nperpendicular to the first tangent; later frames use parallel transport). There\nis no default — a guessed seed would flip as the first tangent turns during an\nanimation and twist the tube by 90 degrees in one frame — so an omitted or\ntangent-parallel `normal` throws. Every segment must meet the next at the same\nposition and tangent. Discontinuities and corners throw a teaching error.\n\nThe closed segment vocabulary is:\n\n- `{kind:'line', start:[x,y,z], end:[x,y,z]}`\n- `{kind:'arc', center:[x,y,z], axis:[x,y,z], start:[x,y,z], sweepDeg}`.\n  Axis is a nonzero normal. Sweep is signed, nonzero, and at most 360 degrees.\n  Start lies in the circle plane and determines its radius.\n- `{kind:'bezier', points:[p0,p1,p2,p3]}`. This is a cubic Bézier with four\n  finite vec3 control points. It uses adaptive five-point quadrature for length\n  and numerical parallel transport; stationary tangents and cusps are errors.\n\nThe runtime projects each original mesh vertex onto the analytic rest\ncenterline and retains its longitudinal coordinate and transverse offsets.\nNormalized arc length maps to the posed path; length changes therefore stretch\nor contract the visible tube. This is a display mapping, **not a tendon tension,\nspool payout, friction, collision, or constant-length solver**. An author must\nsolve those mechanics and pass the resulting centerline. Tube thickness remains\nunchanged. `twistDeg` rotates each cross-section in its transported frame; it can\nshow phase travel in an authored helical braid without moving the centerline.\nIt does not itself simulate material payout.\n\nThe source STEP tessellation is immutable. Each occurrence retains a rest\nmesh and one reusable displayed mesh; unchanged component buffers remain shared.\nStraight STEP surfaces may contain only\nend rings, so the runtime first splits their existing triangles into bands of\nat most `maxSegmentLength` mm in rest arc length. This preserves the exact rest\nsurface, interpolates attributes, preserves CAD face picking, and prevents an\nanimated bend becoming a single end-to-end chord. The default is 1 mm; values\nbelow 0.05 mm are rejected. A per-occurrence 700,000-triangle ceiling throws\ninstead of exhausting memory. Refinement is cached until the rest path or band\nlength changes. Original smooth normals are transformed by the deformation's\nlocal inverse-transpose; silhouettes and technical edges follow the surface.\n\nEvery evaluation starts from rest. Omitting `deformTube` on a later frame or\nclearing animation restores the rest shape. Shared component meshes are never\nmutated. A changed centerline can be independently inspected without a renderer:\n`evaluateAnimationClip` returns `deformations`, keyed by occurrence ID. A\ndeformation is the NUMBERS — `restSpec`, `pathSpec`, `twistDeg`,\n`maxSegmentLength`, `braid` — validated and owned, never the author's arrays and\nnever a compiled path: a morph bake fits on a 96 Hz grid, and a sample that held\nits two arc-length tables would hold hundreds of KB per tube per grid sample.\n`compileDeformation` resolves the two paths through a bounded LRU, for as long\nas the caller is posing with them; `sameTubeDeformation` and `sameTubeRestShape`\ncompare two deformations by value. `compileTubePath`, `sampleTubePath`, and\n`projectTubePath` are exported from `common/tubeDeformation.js`. Line and circle lengths/curvature are analytic;\n`minRadius` for a Bézier is a sampled diagnostic and must not substitute for an\nindependent curvature gate. As with any CAD tessellation, a finite surface mesh\napproximates the analytic posed centerline between its vertices.\n\nFor a smooth CAD core, `braid:{pitch:0.8, depth:0.02, strands:8}` adds a\nprocedural braided surface finish. Pitch and normal-relief depth are millimetres;\nstrands is an even carrier count from 2 to 64. Crossing helices, fine fiber\nlines and lighting relief use rest material coordinates and follow\n`twistDeg` as the tube deforms. This is a GPU normal/color finish, **not added\nCAD fiber bodies or collision geometry**; it remains inside the nominal core's\nrendered envelope. No image texture is fetched and no helical solids are\ncreated. The finish is scoped to that occurrence's material and disabled when\nthe effect is omitted. The original material's source color and shader hooks\nremain in force. Both shader stages — the GPU vertex transport and the braid\nfinish — install through one shared hook (`tubeMaterialShader.js`) that runs\nthem in a fixed order and hands the braid its rest coordinates through one\nvarying, so enabling the braid on a later frame than the deformation renders\nthe same as enabling both at once.\n\n## The runtime is a lazy chunk\n\n`deformTube` is the only producer of a tube deformation anywhere in this\npackage — a step module's effects carry only what an animation frame already\nput there — so a document with no `animation.source` can never reach this code\nand must never download it. `common/tubeDeformationChunk.js` is the boundary:\n`tubeDeformation.js`, `tubeGpuDeformation.js`, `tubeBraidMaterial.js` and\n`tubeMaterialShader.js` load behind one dynamic import, ~29 kB out of the CAD\nViewer's initial bundle.\n\nThe load happens at the single async door every clip must pass through:\n`compileAnimationSource` (and so `loadSourceAnimation`, and `mesh-export.mjs`)\nawaits it before any clip object exists. Declaring an animation is the gate,\nnot calling `deformTube`, because a clip may reach for a tube at any `t` and a\nframe must never fall back to the rest pose. Evaluation itself stays\nsynchronous and always sees a loaded runtime, so an animated tube renders\nexactly as it did when this was a static import.\n\nCallers outside the animation path — the scene's effect resets, the display\nedge-line pass — go through `tubeDeformation()`, which answers `null` until the\nchunk lands. That is exact rather than merely tolerant: every one of those\ncalls is a reset or a replay, and a record can only hold tube state after a\ndeformation was applied. `deformTube` cannot no-op, so it throws through\n`requireTubeDeformation` instead. Code that builds clips by hand rather than\nthrough `compileAnimationSource` — tests, mostly — awaits\n`loadTubeDeformation()` first.\n","packages/core/docs/workspace-resources.md":"# Workspace CAD services and resources\n\n`CadWorkspaceService` is the renderer's domain boundary. A host constructs one\nservice per workspace and lends it to every mounted CAD view. The HTTP adapter,\n`createCadClient`, implements this contract and retains its catalog, request,\ncache and worker-session lifetimes. Disposing a view releases its render session;\ndisposing the service aborts its resources and sessions.\n\nSurface resolution and editing-preview observation belong to the service.\nThe HTTP implementation owns request encoding, bounded polling, subscriber\ncancellation, replacement views and immutable store URL validation. A pending\nderivation is asked after again at 80 ms, doubling to 640 ms, then at a tenth of\nthe wait so far, at most every 5 s: a derivation stuck behind a daemon that cannot\nstart a worker is not asked many times a second until it fails. A surface\nbinding keeps its tree/view, surface input and concrete object identities;\na mismatched view, input, object or URL fails before publication. UI consumes\nthe resulting tickets and never supplies a same-origin transport fallback.\n\n`service.resources` owns JSON, text, binary and size reads, dependency resolution\nand worker tickets. Pass it as `{ resources }` to render loaders, peeks, sidecar\nloads and robot loads. Relative package assets and URDF/SDF mesh references are\nresolved by the provider. Direct glTF buffers and textures are read through it\nas well; the Three.js loader receives temporary object URLs that are revoked\nonce parsing completes. Up to six nested glTF reads run together, each limited\nto 64 MiB. Workers broker nested references back to the owning provider.\n\nA normal HTTP provider gives workers serializable URL tickets, preserving the\nexisting worker fetch/decode path, including the configured HTTP cache policy.\nConfigured headers stay on the provider origin; external nested references do\nnot inherit them. Custom fetch implementations remain host-owned. A provider with a custom fetch reads bounded\nbytes on its owning thread and transfers an owned ArrayBuffer to the worker.\nThe worker never receives a service function or detaches an admitted cache\nbuffer. SURF byte tickets are acquired only after reserving a worker slot, so\nqueued components cannot accumulate eagerly transferred buffers. Recognition reads are limited to 16 MiB. SURF display-cache hits inspect\nthe cached header before requesting a resource ticket, preserving zero-SURF-read\nwarm rendering. A miss with no SURF URL yet (a part that opened warm, before its\nsurface is resolved) fails as not ready and requests no ticket; the caller resolves\nthe surface and asks again. Aborting a worker request retains the existing scheduler and\nowner rules in [resource ownership](resource-ownership.md).\n\nEach service wraps its provider in an opaque cache generation. URL-addressed\nJSON, text, mesh and descriptor caches include this scope; completed package and\nrecognition caches do too. A fresh server-metadata read that observes a changed\nidentity token retires the generation and aborts its pending reads.\nThe provider exposes that generation signal on the main thread. Worker clients\nand recognition subscribe before acquiring tickets, so already-issued URL\ntickets cannot publish a late result after retirement. The signal itself never\ncrosses into a serializable worker message.\nReplacement services and authentication providers cannot inherit another\nservice's URL cache. Mounts borrowing the same service retain warm reuse.\nContent-addressed decoded tessellations retain their existing exact input/object,\ntolerance and payload-version keys and bounded geometry ownership.\n\nThe public low-level core loaders continue to support omitted providers for\nstandalone static HTTP callers. This is an explicit transport API default,\nusing ordinary fetch and URL-addressed caches; it is not a mutable process-wide\nworkspace provider. Shared UI requires its injected service. `loadSource` is the\nstatic/source composition boundary and creates an HTTP provider when none is\nsupplied; docs and the snapshot entrypoint compose theirs explicitly. Hosts\nrequiring custom credentials, native transfer or workspace isolation must pass\na provider consistently to both loads and peeks.\n","packages/ui/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2FREADME.md -->","packages/ui/docs/annotation.md":"# Annotation\n\nThe drawing tools — Pencil and Shapes — are the CAD viewer's own ink, with no drawing SDK behind it. A view has one\nannotation: its marks are projected into the model in 3D, so they stay where they\nwere drawn from every angle, lying on the surface they were drawn on. The STEP, GLB, mesh, robot and DXF renderers offer them\n([Pencil and Shapes](cad-renderer.md#pencil-and-shapes)).\n\n## Pieces\n\n- `annotation/session.js` — `useAnnotationSession`: the annotation's ink, what the tools\n  put down next (the tool of the strip tool in hand, `pencil` — the pen, the highlighter or the eraser — and `shape` — the line first, the arrow, rectangle, ellipse or text — each kept while the other is up; `style`, the colour, stroke width and\n  placement — `surface`, the default, or `plane` — of the tool in hand, kept per drawing kind in `styles`: `pen`, `highlighter` (yellow\n  `#ffe600`; the others neon red) and `shapes`, which all five shapes share; the eraser has none) and the selection. Each add, erase, restyle of the selection\n  (`restyle`) or fill of a closed shape (`fill`, joining its group) is one step\n  in the file view's history (`renderers/kit/shell/useViewerHistory.js`, 200\n  steps, shared with the renderer's own tools; without one the session keeps its\n  own). Choosing what is drawn next never touches ink already down.\n  `clear()` is a step; `discard()` drops the ink and takes its steps out of the\n  history, for a model that changed under it. The session also keeps the views ink was drawn\n  in (`views`, \"view 1\", \"view 2\"…), as labels for the agent: `noteView(view)` names the one a mark goes\n  down in — a view drawn in before that is the same (`annotation/views.js`'s\n  `sameView`: less than 2° of turn, 5% of zoom or pan, nothing else changed),\n  or a new one.\n- `annotation/views.js` — a view as a prompt carries it, in a snapshot job's own\n  words: the camera (always orthographic), Clip when on, the one motion on\n  screen (Explode, or the pose off its default as `kinematics` or\n  `jointValues`), the parts hidden and isolated, and the picture's size. Never\n  how it looks.\n- `annotation/tools.js` — the two strip tools that draw and their tools: Pencil (`freedraw`, `highlighter`,\n  `eraser`) and Shapes (`line`, `arrow`, `rectangle`, `ellipse`, `text`); `toolFamily(tool)`, `styleSlot(tool)` (the style slot a tool draws with; none for the eraser).\n- `annotation/toolbar.jsx` — `AnnotationToolbar`, the sub-toolbar both strip tools share: one row (at every width; a phone's\n  wraps where it has no room) — the tools of the strip tool that is up, a button each, the one in hand pressed (Pencil's Pen,\n  Highlighter, Eraser; Shapes' Rectangle, Line, Arrow, Ellipse, Text); a rule; Color, Stroke width and Placement (On\n  surface, On plane), dropdowns of the tool in hand's own style (disabled under the Eraser), Stroke width's glyph a straight line drawn at the weight (four, 1–6px, unnamed in its menu; Color's and its menus have no heading), then Clear;\n  Undo and Redo are ⌘Z and ⇧⌘Z, with no buttons. A flat view (a DXF) passes `placements={false}`: its ink has nowhere to lie but the drawing.\n  `annotationColors` offers\n  neon swatches and a neutral that reads against the theme: white in dark mode,\n  near-black in light mode. A native color picker adds a custom color.\n  `AnnotationStylePickers` also restyles the selection's ink.\n- `renderers/kit/tools/annotate/AnnotationSelectionBar.jsx` — the bar over the ink Select\n  picked: its colour and weight, Fill for a closed shape (seeded\n  inside its outline as it falls on the screen, `annotation/geometry.js`'s\n  `interiorPoint` and `fillMarkAt`, and projected where the shape lies: On plane on the\n  shape's own plane if its points are coplanar (a fitted plane), else On surface) and\n  Delete.\n- `annotation/geometry.js` — each tool's mark in screen space:\n  - The highlighter (`highlighterMark`) is the pen's smoothed line at one broad width — five times the stroke width\n    (`HIGHLIGHTER_WIDTH_FACTOR`) — 35% opaque (`HIGHLIGHTER_OPACITY`), round-ended, with no `widths`: an ordinary\n    stroke mark (`width`, `opacity`) that no new field or kind describes. `strokeWidthAt` and `strokeSettingOf` convert\n    between a stroke's width and the setting (1, 2, 4) for restyling. Translucent strokes are drawn once-only\n    in the model (`core/common/annotation.js`: a stencil reference per stroke), so its overlapping segments do not darken.\n  - The pen is smoothed as it is drawn, and each point's width comes from its\n    pressure (a stylus's own, or simulated from speed with a mouse), using\n    `perfect-freehand`.\n  - Rectangles get rounded corners.\n  - A fill finds the area inside a shape with\n    `@text-to-cad/core/lib/annotation/fillRegion.js`.\n- `renderers/kit/tools/annotate/AnnotationOverlay.jsx` — the surface over the viewport\n  while Pencil or Shapes is up. It turns every gesture into marks, previews a mark on its\n  own canvas as it is made, and puts it into the model when the gesture ends.\n  Text is written where the surface is pressed: Enter, or a press elsewhere,\n  puts it down as a text mark; Shift+Enter makes a new line; Escape drops it. The\n  surface stops the press's mousedown, which would otherwise take the keyboard\n  back from the text it just opened.\n  Each mark names the view it was put down in, and is projected onto the surface it starts on.\n  The drawing tools have no Select of their own:\n  the viewer's Select picks ink (`RendererShell.jsx`, through the viewport's\n  `annotationAt`), Shift adding to the pick, and Delete and Escape act on it (here too).\n  The camera stays the person's between strokes: the wheel zooms, a right or\n  middle drag orbits, and while Space is held the pointer is the view's.\n- `mountAnnotations(runtime).update(items, selected, hovered)` haloes the ink Select\n  picked, a wider line in the selection's blue behind it, and, fainter, the ink\n  its pointer rests on (the shell's `hoveredAnnotation`, the marks drawn with it too). Ink draws after the model and its\n  highlights (`ANNOTATION_RENDER_ORDER`), so a lit part never paints over it. Nothing else is drawn: no\n  pane, no label. The ink's box (`runtime.annotationBounds`) widens the camera's depth range.\n- `renderers/kit/tools/annotate/annotation3d.js` — projects a mark into the model.\n  - Every point stays on the ray through where it was drawn, so from that view the ink is\n    exactly what was drawn. **Ink lies on the surface it is drawn on.** Every mark has a\n    **level**: the plane of the surface it starts on (a stroke's first point, a text mark's anchor\n    corner, a fill's first outline point), found by `levelOf(runtime, start, size)`. A flat face's\n    level is its own plane; a curved face's, its tangent plane at the start point (the geometry's\n    smooth normal there). The normal is the surface's, **not the view direction**: from any\n    angle, a mark started on a plate's top has the plate's top plane as its level. No sheet, no\n    pane: nothing is made per view, and nothing is held between marks.\n  - **Placement** says what a point does, per mark at draw time. *On surface* (the default): each\n    point drops onto the visible surface under it (a stroke follows a curved face and steps across\n    faces); a point with nothing under it (past the model's edge, over a through-hole) takes the\n    level: its ray met with the level plane, so a stroke that trails off a face carries on at that\n    face's level. *On plane*: every point is its ray met with the level plane — flat across a\n    curved face, staying on the tangent plane where it started, and carried off the surface at\n    the same level. Both modes agree off the surface. A stroke's line is first split into pieces\n    of a few pixels (`subdivideStroke`) in both, so a two-point line reads the surface all along\n    it; the pen's widths are split with it. In On surface a point takes the nearest depth within\n    its own reach — half the stroke's width and a pixel, never a neighbour's, so a slanted face\n    does not lift the ink off it; a fill point by point. Every point is then two screen pixels toward\n    the eye, text eight. Text is a card facing the view, at the level's depth under its anchor\n    corner, in both modes. Ink already down never moves with the setting.\n  - **The level, when the start is over nothing** (in order):\n    1. the surface under the start point (a ray through it);\n    2. the **visible surface nearest to the start on the screen**: in a depth render of the parts\n       over the window (read back at no more than 256 pixels on the long side), the covered pixel\n       with the least screen distance to the start, found by searching outward in rings of pixels\n       from where the start falls (the nearer of two as far wins), then ray-cast through its middle\n       for the surface point and normal there;\n    3. nothing drawn in the window, but a model: the drawn surface **nearest to the window on the\n       screen** — the same render over a region round the window, large enough to hold the model's box\n       on screen (the window's size on each side where part of the box is behind the eye), at low\n       resolution; the covered pixel with the least screen distance to the window rect;\n    4. no model drawn: the plane through the origin facing the view.\n\n    \"Drawn\" is what the depth render sees: hidden parts and what Clip cuts away do not count.\n    Clip's cap faces are not part meshes (stencil fills, `sectionCaps.js`), so they are not in\n    the render: through a cut the level is the surface seen inside. Without a GPU float render the\n    same reads come from rays, at a far lower resolution. The depth renders are made only when a\n    level needs them, once for all the marks put down together (`projectAnnotations`).\n  - **Grazing guard:** a level whose plane is nearly edge-on to the view (|normal · view\n    direction| below 0.08) is replaced by the plane facing the view through the same point, else rays\n    would stretch the mark along the plane.\n  - `projectAnnotations(runtime, marks, size, { placement, topologyAt, level })` projects marks,\n    finding each one's level inside; `level` ({ point, normal }) gives all of them one instead (a fill\n    of a flat shape lies on the shape's plane).\n  - What the model hides of the ink is drawn faint through it (core's `ANNOTATION_HIDDEN_OPACITY`,\n    on the viewer and in snapshots), so a profile drawn through a part's middle is seen.\n  - Every mark is in model coordinates: it stays where it was drawn whatever\n    Explode, Position or hiding a part does to the model. Clip cuts it.\n    `touches` says what it was drawn over, most first, the same for either placement: the faces and edges the\n    renderer names under it (`ref`, a prompt reference, from the shell's\n    `inkTouches`; the STEP renderer's reads them whatever Select's filter picks),\n    each with its part; without it, the parts.\n  - The projected ink is plain data (`AnnotationItem`): an annotation mark\n    (`@text-to-cad/core/common/annotation.js`), kept across scene rebuilds.\n    `mountAnnotations` draws it in a viewer runtime and `update` draws only the\n    marks a change adds; the objects are core's, the ones a snapshot draws an\n    annotation with. The preview\n    holds a finished mark until the scene has drawn it. `annotationDocumentOf` writes the\n    annotation as an annotation file. The file format is unchanged: marks are 3D points; the level is the viewer's own.\n  - A double-click under Select (Look at, `kit/camera/lookAt.js`) is navigation only; it sets\n    no plane for ink.\n\n## Lifetime and capture\n\nThe live annotation is session-only: it is never written to tab or file state. It\noutlives the drawing tools being put down, because the ink stays in the model and the next\none adds to it. It goes when the person clears it, or when the model it was\ndrawn on changes.\n\n⌘C / Ctrl+C while Pencil or Shapes is up copies the view as it is on screen, ink\nincluded, to the clipboard as a PNG.\n\nQuick Edit captures the current view and distinct views with remaining ink when\nCopy Prompt, Queue or Send is pressed (`host.promptFiles`). The snapshot packet\nincludes shared annotation and measurements: up to 1 KiB of compact UTF-8 JSON\ntravels inline, and larger packets are saved to a temporary `.snapshot.json`.\nNo images are rendered at handoff. Save inline JSON to a file before running\n`cadgen snapshot --job <file>` to render those views over the model or an edit of it.\nThe Annotation chip does not copy references or save individual files.\nQueue and Send clear the annotation with the note; Copy Prompt keeps it.\n","packages/ui/docs/cad-renderer.md":"# CAD renderer\n\nThe CAD viewer is the viewport, the floating tool strip and the tool in hand's\nsub-toolbar under it, the file sidebar, reference interactions, measurement and annotation\ntools, animation, loading artwork and alerts, in `@text-to-cad/ui`. The rules of its\nchrome — which tools a file has, how they take and give back the pointer, the file sidebar,\nPreview, the keyboard — are the\n[design system](settings-ui.md); this document is the mechanism behind them.\n\nThere is one renderer per file family over a shared, format-blind [kit](#kit) and\n[shell](#shell). The STEP renderer is under `src/renderers/step`, on the kit like\nevery other family: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`, which\nruns `useRendererShell` exactly as the other renderers do and composes STEP's own\nmodules around it (see the end of [Shell](#shell)). Applications use the registrations and the shared\n`FileViewer`; they do not import another application's source.\n\n## Kit\n\n`src/renderers/kit` is the format-blind half of the viewer: small modules a\nrenderer composes, none of which asks what it is showing. There is one renderer\nper file family, each a vertical slice over the kit: `src/renderers/dxf`\n([DXF renderer](#dxf-renderer)), `src/renderers/glb` ([GLB renderer](#glb-renderer)),\n`src/renderers/mesh` (STL and 3MF, [Mesh renderer](#mesh-renderer)),\n`src/renderers/robot` (URDF, SRDF and SDF, [Robot renderer](#robot-renderer)) and\n`src/renderers/step` (STEP and STP, [STEP renderer](#step-and-source-separation)). The kit imports itself, shared UI\n(`primitives`, `lib`, `drawing`) and the format-blind half of `@text-to-cad/core`\n(`lib/viewer/*`, `lib/perspective.js`, `common/viewSettings.js`,\n`common/sceneSettings.js`, the Render studio); a renderer imports the kit, never\nthe reverse.\n\n| folder | what it is |\n| --- | --- |\n| `viewport/` | `useViewerRuntime` (three.js renderer lifecycle, on-demand render loop and `requestRender`, resize and device-pixel-ratio caps, context loss, keyboard orbit, `setSuspended`, teardown), `gpuDevice` (the page's one WebGL context, shared by every viewport: see [the GPU device](#the-gpu-device)), `previewViewScene` (Preview's view of the file's Render settings: no clip or explode, one scene-quality tier up, `previewSceneQuality`), `framePresentation`, `viewportBuffer`, `renderDepthPolicy`, `sceneObjects` (`disposeSceneObject`), `thumbnail`, DOM helpers. The scene in the viewport is its owner's: teardown calls the injected `disposeScene(runtime)` and `disposeStudio(runtime)` and disposes its lights' shadow maps; a runtime with a renderer of its own (no device) then loses its WebGL context (`forceContextLoss`), since three's shared textures keep every renderer that drew them reachable and only a lost context gives its GPU memory back, and one on the device lets go of its view and never ends the device's context. A torn-down runtime draws no further frame. The orbit controls let go of the page in a layout cleanup, before React detaches the canvas: they listen on its root node, which is no longer the document once it is detached. |\n| `camera/` | `runtimeCamera` (zoom percent against the authored framing, projection and lens sync, perspective snapshots, eased transitions, fit-to-bounds, recentre), `useViewportCamera` (that behaviour bound to a mounted viewport: the perspective kept by a mounted view, the reset that Zoom to fit and the live `resetCamera` share, view-cube presets, which turn the camera and keep its zoom and target), `viewportCameraKit` and `viewportCameraFit`, `orbitControls`, `zoomPivotReanchor` (a wheel step first moves the pivot's depth onto the surface under the cursor, and a perspective pan is scaled by that surface's depth, so Render pans and zooms what the cursor is on at Solid's rate), `zoomSpeeds`, `cameraLens`, `ViewPlaneControl` (view cube). |\n| `look/` | `stageEffects` (lighting rig scaled to the model, floor, glow and shadow catcher, grid and origin axes), the Render studio boundary (`renderStudioChunk`, `studioEnvironmentCache` and its worker). `chromeBackdrop` and `useChromeBackdropColor` (the frame colour around a scene). The surface LOOK is data the viewport resolves and a scene applies to its own materials: `@text-to-cad/core/lib/viewer/surfaceLook.js` (`createSurfaceLook(THREE, root).apply(look)`) does it for any authored material tree. The viewport resolves it with core's `resolveSceneSurfaceLook` (`common/sceneSettings.js`), the resolver the snapshot CLI dresses the same scenes with. |\n| `view-settings/` | The settings model and store (`viewSettingsStore`, `useViewSettings`, `viewerDisplaySettings`, `renderState`), applying a change to a viewport (`useAppliedViewSettings`, `viewUpdateCoordinator`, `viewUpdateGate`, `viewUpdatePlan`), the content of Display's dropdown (`DisplayModeOptions`: the modes and the Grid switch's icons; `DisplayPopover` draws it), and Preview's settings form (`PreviewRenderSettings`). |\n| `tools/` | `FloatingToolBar` (the dumb strip: the tools it is handed, in groups parted by one thin rule, then, only where the host has no navbar, the view's controls, which end the last section the tools have with no rule of their own; after a rule only where the tools are a single group), `SubToolbar` (the tool in hand's one row under the strip, its groups parted by `SubToolbarSeparator`; `SLIDER_SUB_TOOLBAR_CLASS`, the one width of a slider tool's row), `toolModes` (the tool-mode state machine), `ToolModeMenu` (`ToolSettingsMenu`: a tool's settings dropdown, from a chevron right after its button on the strip, or a sliders button in a panel's heading), `ToolPopover` (an ordinary dropdown from its button, on a `side` and `align`ed start or end: the Animation section's Playback settings), `FileSidebar` (the file's sections, whatever the tool: a tile each at the top-left, a tile opening its section in a panel under them, one at a time, sized from its bottom-right grip; on a phone one tile, `phone`, opening one panel with the sections as tabs and its one X; Preview offers its Position and Animation alone, `sections`, its tiles `bare`), `ToolStack` (the bounded column under the strip and sub-toolbar for a renderer's own `toolPanels`, which scrolls only when what cannot give way still does not fit), `ToolPanel` (one panel of the stack, or one section of the sidebar: `fit` says how it gives way when the viewer is short, `resizable` makes a stack panel the person's to size by the grip in its bottom-right corner alone, moving only it, and a panel with a heading can fold to it; its `footer`, a `ToolPanelFooterButton`, is a full-row action under the body that never scrolls), `ResizeGrip` (the one resize grip of a box over the viewport, in a bottom corner: the sidebar's and a resizable panel's at the bottom-right, Quick Edit's at its bottom-left), `toolStackLayout` (every panel's width, each sidebar section's and the resizable panels' sizes, and the folded stack panels: defaults, bounds and their stored record), and the format-blind tools: `annotate/` (`AnnotationOverlay`, the surface the drawing tools' gestures land on; `annotation3d`, its ink projected into the model; `AnnotationSelectionBar`, the bar of the annotations Select picked), `PreviewChrome` (Preview's chrome: the tools view's controls hidden and inert while it is up, and its own corner, `end`, with its settings panel under it, `panel`), `PlaybackMenu` (the Playback settings — Speed, Loop, Autoplay — and `ORBIT_SPEEDS`, Preview's Orbit speeds), `playbar/` (`AnimationSection`, the file sidebar's Animation section: Reset in its heading, the routine where there are several, Play/Pause, the scrubber, Playback settings; `AnimationTimeControl`, the scrubber, and `animationControlsHaveContent`; `animationClock`, `usePlaybackFrames`, `playbackPreferences`: the file's playback settings — Orbit's speed, Autoplay, and the chosen Speed and Loop), `pose/` (the handle overlay, canvas, drag mathematics), `select/` (`SelectFilterMenu`, Select's selection filter on its chevron; `selectFilter`, its pure toggles; `usePointerPick`: taps and hover through a scene's own `pick`, each with where the pointer was; `useSceneSelection` and `sceneReferences`: the parts a scene's pick names, selected, lit and handed to Quick Edit as references), `measure/` (Measure, for any 3D view: `useMeasureSession`, the session; `useMeasureOverlay`, its drawing; `MeasurementCard`; `measurementChips`, its chips and its row under the strip (`MeasurementsBar`); `measureSelection`, Select's press and hover on a measurement; `SceneMeasure`, two points on a scene's surface, and the model's size row), `model-effects/` (Explode and Clip, for any scene that opts in: `ModelTools`, their strip tools and slider rows; `useSceneEffects`, the viewport applying them to the scene; `motionExclusion`, one motion at a time), `sceneTools` (the tools a scene's renderer composes around the shell: the tool in hand, Pencil and Shapes, Measure and the model effects), `quick-edit/` (`QuickEdit`, the note to the agent, and `quickEditPrompt`, the context it builds and the text it copies). Screenshot capture is `@text-to-cad/core/lib/viewer/screenshotCapture.js`. |\n| `inspector/` | `FileSheet` and its row and section primitives, `modelTreeSearch` (`useTreeSearch`, the ranked flat search every tree shares), `VirtualRows` (a long tree's rows, windowed), `referenceRows` (`InfoRow`, `MonoValue`, `CoordValue`), `kinematicsControls` (the `Pose` row that heads every Position section: Default, the named poses, Custom). The tree row and filter box are `primitives/tree-row` and `primitives/tree-filter`. |\n| `status/` | `LoadingIndicator` and `ViewerLoadingOverlay`, `ViewerAlertCard` (the card over the viewport for every alert, and `useAlertDismissal`: a card put away, and its icon in the navbar that brings it back) and `reportIssue` (`alertIssueUrl`: its Report Issue's new issue), `ViewUpdateStatus`, `loadingState` (`viewerLoadingState`), `loadAlerts` (`failureAlert`, `noGeometryAlert`). |\n| `shell/` | The host glue every renderer needs that is not about its scene: see [Shell](#shell). |\n\n**What a view opts into.** Display's settings and `resolveViewSettings` take explicit\nlists, `features: { modes, settings }` (`ViewFeatures` in\n`@text-to-cad/core/common/viewSettings.js`): the modes it lists and the settings\nits menus offer (`VIEW_SETTING_IDS`). A setting left out does not affect the\nscene, and saved settings are never rewritten. `viewFeaturesForKind(kind, { parts })`\nis the one rule of which list a file gets: `ALL_VIEW_FEATURES` for a STEP;\n`PARTED_MESH_VIEW_FEATURES` (Solid, Clip and Explode) for a GLB of several rigid nodes\nthat draw; `MESH_VIEW_FEATURES` (Solid and Clip) for any other 3D file; and\n`EDGELESS_VIEW_FEATURES` (no Edges, Clip or Explode) for a drawing. Render is no mode. Every\nrenderer hands its list to the shell, which configures the view-settings store\n(`configure({ features })`) and Display's settings from it, and to its Explode and Clip\ntools. The headless renderer (`renderMeshScene.js`) resolves a job of each family under the\nsame rule.\n\n**Tools.** The strip draws the list it is handed: `{ id, label, icon, active,\ndisabled, onSelect, description?, menu? }` — a press is a tool's only action; a tool with\nsettings has them as `menu`, a chevron right after its button (Select's filter, the only\none), and what it can be set to while it is up is its sub-toolbar. Display is not a tool:\nits settings are a dropdown from its button at the navbar's right end, before Preview\n(`kit/shell/DisplayPopover.jsx`), a 3D view's alone. A renderer\nbuilds its own list from `shell.tools.own(...)`, adding `shell.tools.drawing` (Pencil and Shapes) where it offers\nthem, declared in its `toolModes` as two modes (`pencil`, `shapes`) (a press on the tool in hand does nothing); STEP's is in `step/StepSurface.jsx`.\nRoutines play from the file sidebar's Animation section, which the shell adds for any file with\nroutines (`kit/tools/playbar/AnimationSection.jsx`), in the tools view and Preview alike. Preview is not a tool: its button, after Display at the navbar's right end, enters it. Nor is Quick Edit: it is a box at the top-right, there while something is picked or annotated. `createToolModes({ defaultMode, modes })`\nanswers what a press does (`next`), what a saved tab may record (`persisted`) and\nwhich tool a file opens in (`restore`); the STEP declaration is\n`CAD_TOOL_MODES` in `workbench/constants.js`. The scrubber follows the clock on\nthe runtime it is handed (`runtime.clock`, an `AnimationClock`); Position's knobs\noverlay (`tools/pose/`), Select's while its filter ticks Position, takes a plain handle list, as a prop or (for an\nowner that poses its model outside React) as a ref whose list it replaces per pose.\n\n**The scene contract** (core's `lib/viewer/sceneContract.js`, a JSDoc typedef that `kit/scene.js`\nre-exports, because the snapshot CLI holds the same scenes): `{ object3D, bounds,\nrestBounds?, dispose(), setSurfaceLook?(look), setShadowReception?(receives),\nkeepsAuthoredFinish?, complete?, pick?(ray), placedObjects?() }`, and for a family's scene\nthe tools' half: `units` (the file's unit against the scene's coordinates, core's\n`sceneUnits.js`), `setClipPlanes(planes)`, `explodeParts` and `setExplode(amount)` (a GLB),\nand, for a scene whose parts are picked by an id of the file's own, `hasPart(id)`,\n`describePart(id)` and `setHighlight({ hovered, selected })`.\nThe viewport adopts `object3D`, frames `restBounds`, lights and floors `bounds`,\nand hands over the surface look the Display settings resolved\n(`{ materialSettings, authored, surface: { style, opacity } }`); it only ever\ndetaches a scene, whose owner disposes it. `buildModel`\n(`@text-to-cad/core/common/cadScene.js`) exposes `object3D`, `bounds`, `restBounds`\nand `dispose()`; the GLB and mesh renderers' scenes implement all of it but `pick`,\nand the robot renderer's all of it. A family's `pick(ray)` answers the first surface Clip has\nnot cut away, with the point it hit (`point`, for Measure). `pick(ray)` is what `usePointerPick` calls: the\nhook owns the pointer (which press is a tap, one hover pick per frame, the\ncursor), the scene says what is under the ray, and its renderer says what a hit\nmeans. A tap acts at once: nothing waits to tell it from a double-click.\n`placedObjects()` is for the near/far fit alone: a camera inside a mostly empty\naggregate box can still be well outside everything visible in it, so a scene that\nplaced many things says so and is fitted on them instead of on its whole box.\nShadow reception is the viewport's for every mesh of a scene, unless the scene\nimplements `setShadowReception`: a scene with unlit or see-through surfaces is told\nthe setting and applies its own rule (a STEP's watch crystal never takes a shadow).\nA mesh names where \"Color by part\" deals it a palette colour in\n`userData.cadFillIndex` (otherwise meshes take the palette in traversal order),\nand a material with no source colour says so with `userData.cadSourceColor = false`.\n\n**The rule and its check.** Kit code names no file format, no file-kind switch\nand no STEP-assembly concept (topology, selectors, display records, explode,\nsection clipping), in code or in comments; only `view-settings/` may name the\nClip and Explode sections a view opts into, and `tools/model-effects/` the two effects\nit applies to whatever scene opts in. `npm run check:boundaries` runs\n`scripts/test/check-kit-boundaries.mjs`, which scans every kit source for imports\nof a renderer or of a file-family core module and for those words, against a\nshort allowlist that carries a reason per line and fails when an entry goes\nstale. The same script holds every split-out renderer to its slice\n(`RENDERER_SLICES`): a renderer imports the kit, `renderers/workspace` (the\nbackend connection a file is prepared against, the host's viewer preferences and\ncommand types, and `useWorkspaceDocument`), shared UI and core, and never another\nrenderer. It holds the host's file viewer (`src/file-viewer`) to the mirror of\nthat rule: the file viewer mounts a renderer through the registry and imports\nnone itself, so no host pays for a family it is not showing. The\nunbound-identifier test (`src/renderers/unboundIdentifiers.test.js`) scans the\nwhole renderer tree, so a renamed or added slice is covered the moment it\nexists. The same script's `checkSharedSceneBuilders` holds the rule of the next\nsection: each family's builder, loader, opening pose and look are imported by the\nrenderer AND by the snapshot's `common/headlessScene.js` from one core module, are\ndefined nowhere else, and no module on the snapshot's render path imports a mesh\nflattener.\n\n### One scene builder per family: the viewer and the snapshot CLI\n\n`cadgen snapshot` of a GLB, an STL, a 3MF or a robot description draws the scene the\nviewer draws, because both call the same builder. A family's scene construction lives in\n`@text-to-cad/core` (no React, no DOM beyond three.js); its renderer here keeps the loading\nstate, the tools and the panels, and the snapshot page (`common/headlessScene.js`, routed\nfrom `common/headlessRenderEntry.js`) keeps only what a still has and a viewport does\nnot: the camera fit, the studio set up per output, and PNG encoding.\n\n| family | builder (core) | viewer caller | snapshot caller |\n| --- | --- | --- | --- |\n| GLB | `lib/render/glbScene.js` `createGlbScene`, over `loadRenderGlbDocument` | `glb/useGlbScene.js` | `GLB_FAMILY` |\n| STL, 3MF | `lib/render/meshScene.js` `buildMeshScene`, over `loadRenderMeshByUrl` | `mesh/useMeshScene.js` | `MESH_FAMILY` |\n| URDF, SRDF, SDF | `lib/urdf/robotScene.js` `createRobotScene` over `robotParts.js`, loaded by `lib/urdf/loadRobot.js` | `robot/RobotRenderer.jsx`, `robot/useRobotDocument.js` | `ROBOT_FAMILY` |\n| STEP | `common/cadScene.js` `buildModel` | `step/scene/stepScene.js` | `headlessRenderEntry.js` |\n\nBoth hosts dress a scene the same way: the look from `resolveSceneSurfaceLook`, shadows\nwhile the lighting is on, `keepsAuthoredFinish` for Inspect's reflection environment, and\nthe ground sized from `restBounds`. Both cut and explode it the same way: Clip through\ncore's `lib/viewer/sceneClip.js` (the plane in the box the camera frames, `at` in the\nfile's unit, the cut capped as a STEP's is), Explode through the scene's `setExplode`\n(`lib/viewer/sceneExplode.js`, a STEP's layout over the file's tree). A robot's parts a\nQuick Edit's view hid come back in its job's `selection.hide`. A robot opens at `robotOpeningPose`\n(`lib/urdf/motion.js`: every joint's default, then an SRDF's `home` state) and a\nsnapshot's `--joint-values` go on top through the same joint matrices. What a snapshot\ncannot express, it refuses rather than approximates: a GLB's clips play in the viewer's\nAnimation section and have no snapshot flag. The pieces are pinned by `common/headlessScene.test.js`\n(core), `kit/view-settings/renderState.test.js` (one look from either route) and\n`tests/python/packages/cadgen/test_snapshot_family_scenes.py` (real snapshots, read as\npixels).\n\n### Shell\n\n`kit/shell` is what a file-family renderer needs from its host that is not about\nits scene. A renderer loads its document, builds its scene (`kit/scene.js`) and\ncalls one hook; the shell owns the rest.\n\n| module | what it is |\n| --- | --- |\n| `useRendererShell.js` | The hook. Per-file state through the host, the Display settings store and the content of Display's dropdown (`shell.display`), tool modes (Pencil and Shapes are the strip tools the shell itself owns, `shell.tools.drawing`; Display, Preview and its controls are the frame's; `toolModes` is omitted altogether by a renderer with no tools of its own), the file sidebar's and tool stack's layout (a viewer preference: `services.preferences.toolStack` — each sidebar section's size, the resizable panels' sizes, the folded stack panels — changed by one patch per gesture, `frame.changeToolStack`), prompt snapshots (the host's `captureRequest`), the clipboard screenshot (the drawing tools' Copy), what Quick Edit takes from the view (its file, how a copied prompt spells a path, the view as a picture, the viewer's Escape), the one Preview state (`shell.previewing` / `setPreviewing`, from `usePreviewState`; every way in and out goes through `setPreviewing`, which mounts Preview's view or takes it away and touches nothing of the editor's), Preview's scene (`frame.previewDesiredScene`: `previewViewScene(store.renderScene())`, Render over the file's settings as Preview draws them) and its viewport (`frame.previewViewRef`), Preview's Orbit (`shell.orbiting`, transient, off at every entry and exit), Render's studio code and environment prepared 600 ms after the model is on screen at Preview's quality, into the GPU device's cache Preview draws from (`previewPrepare`; it changes nothing the editor draws; not on a software renderer, the viewport's `softwareRendering`, where Preview prepares as it is entered), and Autoplay (`shell.autoplay`), alerts, shortcuts, and the live command surface (`setCamera`, `capture` and `resetCamera` act on the view on screen: Preview's while it is open). It owns no zoom control: the shell has none. |\n| `RendererShell.jsx` | The frame: viewport box; one centred column at the top centre (`TOOLBAR_POSITION`, `data-cad-tool-groups`) holding the tool strip (the renderer's tools; the view's controls — Display, the active mode's sphere, whose dropdown is `DisplayPopover`, the modes and the Grid switch, then Preview, a play triangle, for a `previewable` view — are the navbar's, portalled into `view.navbarSlot`, and end the strip only where there is no navbar), under it the tool in hand's sub-toolbar (the drawing tools' `AnnotationToolbar` while Pencil or Shapes is up, else the renderer's `subToolbar`: Explode's, Clip's), the update status, and the tool stack (`ToolStack`: the renderer's `toolPanels`; the column stops above the cube's line, `VIEWPORT_STACK_BOTTOM`); the file sidebar at the top-left (`FileSidebar` over the renderer's `sidebar`, `SIDEBAR_POSITION`: its tiles 8px in from the top and left, the open section's panel under them down to 8px off the bottom; on a phone one tile at the left of the strip's line, the strip padded clear of it, `TOOLBAR_POSITION_PHONE_SIDEBAR`, and one tabbed panel down to Quick Edit's peek, `SIDEBAR_POSITION_PHONE`); Quick Edit at the top-right; the view cube in the bottom-right corner; Preview's controls (Preview settings and Exit preview in the navbar slot, or, with no navbar, at the view's top-right, `PreviewChrome`'s `end`; the settings, a toggle whose panel docks at the view's top-right, `panel` (`PreviewSettings.jsx`: Orbit and its speed, then Preview's own settings, `frame.previewSettings`), then Exit preview, an X — and the file sidebar's Position and Animation alone, bare, at the top-left, `PREVIEW_SECTIONS`; nothing under the model); Preview's view (`PreviewView.jsx`, `data-preview-view`), mounted over the editor's viewport box while `shell.previewing` and unmounted as it closes, the editor's viewport `suspended` under it; the cover that answers the press (\"Preparing preview…\", `data-preview-cover`) until Preview's view has presented its first frame (`onPresented`), no finer detail is on its way (quality neither preview nor refining) and its picture has converged (`pictureProgress`) — lifting at once on a view the shell set moving (Orbit, a routine playing), never on a picture that detail started again (`previewCover.js`); at five seconds it stops waiting for detail and lifts on the picture converged, a second later at the latest, with no cover on the way out; the Animation section after the renderer's sidebar sections, for a file with routines, and the file's chosen Speed and Loop applied to its routine; the loading overlay; and the alert card. While the model loads, and once a load has failed in a way the model did not survive (an alert that cannot be put away), it draws none of its controls: only the load, or the card saying why, with the update status at the top centre in the strip's row; in a compact host (`appearance.compact`), none at all: the model alone. Nothing sits at the bottom centre. It takes `studio`, the renderer's own Preview while it is open: `{ scene, overlay(viewport, drawn), animation, runtimeLifecycle, onCameraSettled }` — the scene Preview draws, built by the family's own builder over the editor's geometry, what drives it from inside Preview's viewport (handed the Render scene it draws), Preview's routine (the Animation section's in Preview; Autoplay starts it on entry), and Preview viewport's lifecycle and settle. It takes `references` (what is selected, in the prompt grammar: what Quick Edit attaches and counts), `referenceChips` (how Quick Edit shows them) and `copySelection` (what ⌘C / Ctrl+C copies while the renderer's own tool is up and something is selected; null when nothing is), and, under Select, `onSelectPress`, `onSelectHover` and `selectsAnnotations` (whether Select's filter picks the annotations), and `inkTouches` (what the model is under a point of the view, `(clientX, clientY) => [{ part, ref? }]`: what the drawing tools record a mark as touching). One DOM structure (`data-slot=\"cad-file-view\"`, `data-cad-surface`, `data-cad-scene-backdrop`, `data-cad-toolbar`, `data-view-controls`, `data-cad-sub-toolbar`, `data-cad-file-sidebar`, `data-cad-tool-stack`, `data-tool-panel`) for every renderer. `frameProvider` wraps the WHOLE frame in the renderer's own context — the sidebar and the tool stack as well as the viewport, because they read it — and `onCanvasPointerDown` is a press that landed on the model, for a renderer with something to put down when the person reaches for it. The frame focuses itself on such a press either way. |\n| `ShellViewport.jsx` | The three.js viewport around ONE kit scene: `useViewerRuntime`, `useViewportCamera`, the look (rig or studio, environment, background, floor, grid, axes), the drawing tools' surface and the annotations' ink in the model, the view cube (bottom-right, `VIEWPORT_CUBE_BOTTOM`; not mounted in Preview's viewport, while the model loads, or when the shell says so, `viewCube`), frame presentation and the queued view-settings handshake. Its children may be a function of the viewport (`{ runtimeRef, hostRef, mountRef, viewerReadyTick, commitScene, syncSceneBounds }`), which is how a renderer mounts its own overlay or pointer pick. A scene that changes IN PLACE (it arrives in pieces, swaps its detail, is rebuilt under one identity) calls `commitScene()` from its own effect: the viewport re-reads what it placed, fits the stage and the depth range and applies the framing rules THEN, because a child's effects run before the viewport's own adoption effect. The one thing a commit never does ahead of the viewport is FRAME under a camera that is about to change: when the same render also changed the lens, the projection or the viewing mode, the stage is adopted at once and the framing follows once the camera has been given those props (a stored camera applied under the old projection and then converted comes out about a sixth smaller). A scene is framed once, on its `restBounds`; a STEP package's is the box its `assembly.json` declares (`bbox`), final from the first publish. A scene that says `complete: false` (a package that declares no box) is framed on what has arrived and once more when it is whole, unless the camera on screen is by then the person's: one they dragged, wheeled, turned by keys or the cube, zoomed to a selection or set through `setCamera` during the current mount, or one a reload restored from the file's view. A rebuild of the open file never re-frames; only Zoom to fit does. A file opened again after the tab left it starts with fresh framing, since leaving it dropped its view. `studio` makes it Preview's viewport (opened at rest, framed afresh from the default direction, keeping no camera, no cube and no drawing tools; `orbit` turns the camera at `orbitSpeed`); `suspended` makes it draw nothing while another view covers it, and draws a frame asked of it meanwhile synchronously, before paint, as it is uncovered (`runtime.setSuspended`). `preserveInteractionPixelRatio` keeps the idle pixel ratio while the camera moves (a scene drawn with hairlines, and Preview's orbit), and `runtimeLifecycle` (`onRelease(runtime, { handoff })` while the WebGL renderer is still alive, `onContextLost()`, `onInitializationError(error)`) is for a renderer that hangs its own objects or in-flight work on the runtime. `syncSceneBounds()` re-fits lighting, shadows and the floor's height to a scene that moved its own bounds, with no React render and no reframe; a sync that finds the bounds (and every setting the fit reads) as they were refits nothing and keeps the shadow maps (`look/stageFollow.js`). What is SIZED stays sized from the rest placement, in Inspect and in Render alike: the grid and stage (`sceneRadiusForBounds` on `restBounds`) and the Render studio's floor plane (`applyPhotographicStudio`'s `groundBounds`), so a pose or a playing routine never rescales or slides the ground under the model; `zoomToBounds(bounds)` frames part of the scene. Read-only test seams, the view on screen's (Preview's while it is open, the editor's again as it closes): `window.__cadCamera()` (the live camera, its depth range included) and `window.__cadStage()` (the ground's radius, the bounds the stage is fitted to, the floor's height, the studio floor's size and centre). |\n| `PreviewView.jsx` | Preview's view: a `ShellViewport` of its own (`preview`, `orbit`, `orbitSpeed`), with its own view-update coordinator over `frame.previewDesiredScene`, drawing the renderer's `studio.scene` and mounting `studio.overlay(viewport, drawn)` inside it. Its canvas, scene, camera and look are its own; only the GPU device is shared with the editor's, which it never reaches. `onPresented` says when its first frame is on screen. |\n| `usePreviewScene.js` | Preview's scene for a family whose core scene has `createSibling()` (GLB, mesh, robot): the editor's scene's sibling, made as Preview opens and disposed as it closes, before the scene it shares geometry with. |\n| `fileView.js` | The file's view `{ version, camera, display, playback, renderer }`, read forgivingly and written exactly: the camera (restored in place of the open-time fit; null fits), the Display settings, the playback settings (`tools/playbar/playbackPreferences.js`), and the renderer's own slices, each `{ signature, value }` and restored only under the signature the renderer declares now (`readFileView(raw, signatures)`); the camera, the display and the playback are always kept. The host keys it `[file path, renderer id]` in the tab store, which keeps it for the file on screen alone. Not in it: the tool in hand, a selection, measurements, ink, Preview, a routine's time — every open starts those afresh. |\n| `liveBinding.ts` | `attachLiveBinding`: the live command surface. Base commands (`readState`, `setCamera`, `resetCamera`, `setDisplaySettings`, `capture`, `thumbnail`) mean the same for every renderer; a renderer ADDS commands by name and DECLINES the known host commands (`HOST_LIVE_COMMANDS`) that make no sense for it with the sentence the caller reads. Binding fails when a renderer does neither. |\n| `promptContext.js` | `createViewPromptContext` (a snapshot and what it depicts) and `promptDeliveryError`. A renderer with a reference vocabulary of its own passes `promptContext` instead, and may then return that vocabulary from `promptReferences`. |\n| `loadReport.js` | `shellLoadReport`: what the shell asks the status kit about one document load, as one pure function, and all it returns is the loading presentation over the viewport. A renderer whose document is a plain download passes `load` and nothing more. One whose document can be EDITED or PREVIEWED while it is open knows more than the shell can: `load.editPending` (queued work of the person's own, with nothing of it on screen), `load.currentPreview` (that work IS what is drawn, so the wait is over even though the write is not) and `load.finding` (the file is not even located yet). An alert is `load.alert`, which the frame draws as the alert card (`ViewerAlertCard`: every alert; one the model survives, a warning beside it included, can be dismissed). |\n| `useViewerShortcuts.js` | Which mounted viewer an Escape or a ⌘C / Ctrl+C belongs to (focus inside it, or the page after a press inside it); the renderer says what Escape means, and a copy is the active tool's (the drawing tools' view with its annotations, else the selection's references, `copySelection`). The order is [the design system's](settings-ui.md#keyboard). |\n| `viewportLayout.js` | The chrome's geometry: its inset from the viewer's edges (`VIEWPORT_INSET_PX`, 8px), the strip's row (`VIEWPORT_TOP_BAR_PX`, 34px), the cube's size and place (bottom-right, 2px from the right, `VIEWPORT_CORNER_INSET_PX`, and `VIEWPORT_CUBE_BOTTOM`, 8px off the bottom so its axes stay inside the view, plus `--cad-viewport-corner-bottom`, which a host whose control floats over that corner sets to lift it clear), the tool column's bottom (`VIEWPORT_STACK_BOTTOM`: an inset above the cube's line). |\n| `ViewportContextMenu.jsx` | The viewport's menu on a secondary TAP (a secondary drag pans; primary and secondary together is the pan chord). The gesture, the anchor, the clamping and the dismissal are the shell's; the ITEMS are the renderer's (`contextMenuItems(press)`), asked at the moment of the press, and `onContextMenuOpenChange(open)` says while the menu is up. A renderer that passes no items has no viewport menu at all — every renderer but STEP. |\n\n```jsx\nconst shell = useRendererShell({\n  view,                 // RendererViewProps, unchanged\n  services,             // { preferences, onPreferenceChange, live?, captureRequest?, acknowledgeCommand? }\n  resource, modelKey, revisionKey,\n  features,             // ViewFeatures: the Display sections this family opts into\n  toolModes,            // createToolModes({ defaultMode, modes }), or omitted: no tools of its own\n  previewable,          // true for a 3D view: it offers Preview; omitted (a 2D view): no Preview, and a request keeps the normal view\n  scene,                // KitScene | null\n  load,                 // { busy, updating?, progress?, alert? }: the renderer's document load\n  animation,            // routine runtime with its own `clock` (and `onRelease`), or null\n  live,                 // { commands?, declined?, state? }\n  // optional: promptReferences, promptContext, escape, sceneScaleMode, preview, tool,\n  //   viewSettings, viewerRef, onCameraSettled, onRuntimeAlert, presentationReport,\n  //   preserveInteractionPixelRatio, runtimeLifecycle,\n  //   rendererState   { signatures, read }: the renderer's slices of the file's view, read when it is written,\n  //                   each restored (by the renderer, `readFileView(view.state, signatures)`) under the same signature\n});\n// Renderer-facing, beside `tools`, `display`, `toolMode`, `selectTool`, `previewing`, `requestRender`:\nshell.requestFrame();            // a frame that keeps the shadow maps: a highlight moves and reshapes no caster\nshell.syncSceneBounds();         // the scene moved its bounds (a pose): the stage follows, no render\nshell.scheduleStateSave();       // a slice changed: write the view soon, and on unmount\nshell.playback; shell.setPlayback(patch); // the file's: Orbit's speed, Autoplay, the chosen Speed and Loop\nshell.orbiting; shell.setOrbiting(on);   // Preview's Orbit, transient: off at every entry and exit\n// The shell adds Display and Preview (a `previewable` view's, in the navbar), Preview's controls (Preview\n// settings, Exit preview), the Animation section for a file with routines, and the sub-toolbar of its own tool (the drawing tools').\nconst tools = [shell.tools.own({ id, label, icon, menu }), shell.tools.annotate].filter(Boolean); // []: no tools, and no strip\n// subToolbar: the renderer's tool in hand's row; sidebar: the file's sections, each a ToolPanel, whatever the tool\nreturn <RendererShell shell={shell} tools={tools} subToolbar={toolRow} sidebar={<>{treeSection}{positionSection}</>}\n  viewportOverlay={viewport => <PointerPick viewport={viewport} scene={scene} enabled={selecting} onPick={pick} onHover={hover} />} />;\n```\n\nA CAD renderer's controls are the shell's chrome: its `tools` on the strip, the tool in\nhand's `subToolbar` (`kit/tools/SubToolbar.jsx`) under it, and its `sidebar`, the file's\nsections down the left edge (`kit/tools/FileSidebar.jsx`), each a `ToolPanel` drawn as a\nsection, whatever the tool: a STEP assembly's Model tree and Position, a robot's Links, an\nSDF's metadata and Position. Sections are their content's height and the bar scrolls as one,\nup to all the room down the left edge or the cap a person set from its bottom-right grip\n(`kit/tools/ResizeGrip.jsx`, Quick Edit's), kept under `sidebar` in the tool stack's layout;\na section folds where there are several, kept by `id`. A tree has no X and is on screen under\nevery tool (out of sight only in Preview, kept mounted with its state); a pick scrolls its row\ninto view there. A renderer may also hand `toolPanels`, the tool stack under the strip and\nsub-toolbar, each panel its content's height, with `fit` saying how it gives way on a short\nviewer: `\"tree\"` first (to 128px or its content), then `\"details\"` together, each in\nproportion to its height (to 96px or its content), never `\"fixed\"`; the column scrolls only\nif the rest still does not fit. Every panel is `TOOL_PANEL_WIDTH` wide (164px, a six-tool\nstrip's); `resizable` makes a panel the person's to size on its own, from the grip in its\nbottom-right corner alone, kept under its `id`. STEP and the robot renderer hand none.\n\nA renderer whose model moves outside React (a robot's pose) keeps that state in\nits own store: `rendererState.read()` is called at the moment the view is\nwritten, so the last write before unmount is saved; `shell.scheduleStateSave()`\nsays it changed; `shell.syncSceneBounds()` carries the moved bounds to the stage.\nNone of them renders a component.\n\nThe workspace half of a renderer's surface is one hook too\n(`renderers/workspace/useWorkspaceDocument.js`): `useWorkspaceDocument({ view, data })`\nreturns the live catalog entry of the prepared file, the prompt `resource`, the\n`services` object the shell takes and the host's commands;\n`workspaceLoadAlert` turns a catalog or loader failure into `load.alert`; and\n`useDeclinedSelectReference` consumes a host's request to select a reference in\na file that has none without changing the view. A renderer is then its\nscene hook, its tool list, its tools' rows and its sidebar sections, if it has any\n(`glb/GlbRenderer.jsx`, `mesh/MeshRenderer.jsx`, `robot/RobotRenderer.jsx`). The DXF renderer uses the workspace module without the\nshell: it has no scene, so it reaches the same catalog entry and commands directly.\n\nThe shell's behaviour has one real-browser test,\n`kit/shell/RendererShell.browser.test.mjs` (deferred files, warm reopen, isolated\nper-pane state, Preview's camera, gated Display sections, settings that never\nreplace the canvas), and the drawing tools have one scenario (`harness/drawScenario.mjs`)\nrun under the shell (`kit/tools/annotate/Annotate.browser.test.mjs`).\n\nThe STEP renderer is on the shell end to end: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`,\nwhose frame, file view and host glue are the shell's. What STEP keeps of its own is\nwhat only a STEP has — its slices of the file's view (`workbench/stepViewSlices.js`,\nread and written by `workbench/useStepView.js`: the tree's expansion, hidden parts and\nisolated assemblies, the pose, the large-file setting, each behind the signature that says\nwhether it still fits the file), its prompt context, and the resource its live state reports while a rebuild is held\nbehind the previous mesh (`live.resource`). The surface composes modules of its own around\nthe shell: Measure's session (`kit/tools/measure/useMeasureSession.js`, the kit's), the pose and routines\n(`workbench/useStepMotion.js`), the context menus as pure descriptors\n(`file-view/stepMenus.js`) and what a selection or a node copies (`file-view/stepCopy.js`).\nThe shell was widened for it rather than forked: it accepts display settings a renderer\nmade earlier than it could hand them back (`viewSettings`), a viewer handle the renderer\nowns (`viewerRef`), externally held tool state (`tool`) and preview state\n(`preview`, from `usePreviewState`: STEP's picking and recognition gates run before the shell's hook),\nand a load report that knows about finding, editing and previewing.\n\nIts safety net is `step/StepRenderer.browser.test.mjs`, which opens a real `.step`\nin a real browser: a two-part component-SURF package with one revolute mate, one\nnamed pose and one routine, committed under `step/__fixtures__/step/` and served by\n`harness/stepScenario.mjs` exactly as the backend serves one. The same two shapes\nare also served as a package that really ARRIVES IN PIECES\n(`stageProgressiveFixture`): twenty-five components held one batch at a time, so\nthe loader publishes at 8, at 24 and at 25 and the MIDDLE publish is an in-place\nchange with nothing else moving. That is the only shape of load that can show what\n`viewport.commitScene()` is for — two components publish once, and a package whose\nsecond publish is its last is re-adopted by the viewport for reasons of its own.\n`kit/tools/annotate/Annotate.browser.test.mjs` covers the drawing scenario under the shell,\nand each renderer's own browser test asserts that the STEP-only Display sections\nare absent from it. The shell surfaces only STEP uses so far (the viewport menu and\nits open report, the camera-settled report, a scene\nthat arrives in place, the pixel ratio kept for hairlines, the runtime lifecycle,\nand what a renderer says about a load that is more than a download) are driven\nthrough `renderers/shell-harness` in `RendererShell.browser.test.mjs`.\n\n## DXF renderer\n\n`createDxfRenderer` (`@text-to-cad/ui/renderers/dxf`, id `dxf`) shows a `.dxf`. The CAD\nrenderer does not match one.\n\nIt is **not on the shell**, and that is the whole design: a DXF is a finished 2D\ndocument, so the pane is a canvas and the drawing is painted on it. No three.js\nviewport, no scene, no Display settings, no Preview. The questions the old\nMaterial/Bends tabs answered were about a sheet-metal part the viewer was inventing\nfrom the file; a drawing is not that. What it has is what a person needs to talk about\na drawing — the kit's chrome, composed around the canvas rather than around\n`ShellViewport` (`DxfRenderer.jsx`): the strip at the top centre, the tool's row under\nit, the file sidebar, Quick Edit at the top-right.\n\n- **Tools** (`dxf/tools.js`): **Select** (in hand; the view's hand too: a drag pans, a\n  tap picks), **Pencil**, **Shapes** and **Measure**, no view controls (Display, Projection and\n  Preview are a 3D view's). Select picks, front to back, a measurement, then ink, then\n  the drawing's ENTITY under the pointer (`drawing2d/entities.js`'s `entityAt`: within\n  6 px, a fingertip's 16; the lines over a hatch before the hatch; nothing on a hidden\n  layer); Shift adds. Its selection is the kit's (`useSceneSelection`,\n  `useSceneReferences`) over a drawing \"scene\" (`createDrawingScene`), lit over the\n  drawing (`drawEntityHighlights`). Its cursors are the 3D views': the arrow, pressed\n  or dragging too, the pointing hand over what a press picks. The drawing tools are the kit's\n  layer (`AnnotationOverlay` with a flat view's `surface`, `dxf/drawingAnnotation.js`):\n  ink lies on the sheet in drawing coordinates (x, y, 0), on no part, painted with the\n  drawing (`drawing2d/marks.js`); its row has no Placement. Measure is the kit's session,\n  overlay and chips (`useMeasureSession`, `useMeasureOverlay`, `measurementChips`) over a\n  plan-view camera (`drawing2d/planView.js`'s `planProjection`); a pick snaps\n  (`snapAt`) to an end, a circle's or arc's centre (`center`, \"Centre\"), else a point on\n  a line, and reads in the unit `$INSUNITS` declares — \"units\", never millimetres, when\n  it declares none. With Measure in hand its row under the strip lists its measurements\n  (`MeasurementsBar`), none while nothing is measured.\n- **References**: an entity by its DXF handle, `drawing.dxf#h2F` (`drawingReferences.js`);\n  everything a block reference draws is its INSERT, a dimension is one entity. Quick\n  Edit's chip names it by type and layer (\"Circle on CUT\", \"Block M5_NUT on HARDWARE\"),\n  its card the handle, type, layer, block, centre and radius.\n- **Layers**, a file-sidebar section, only where the drawing has the structure to read\n  (`layersSectionShown`): four or more layers drawn on, or any block reference. Each\n  layer's pen, name, entity count and eye (hidden: not drawn, picked or snapped to), and\n  under it the block references placed on it, a click picking one.\n- **Quick Edit** carries the picks and the ink (never the measurements: they are the\n  person's); its views are the window on screen as a plan-view camera (`planCamera`: looking\n  straight down, `up` +Y, `orthographicHalfHeight` the window's half height), which `cadgen dxf\n  snapshot --job` frames again, drawing the job's annotation over the drawing.\n\n- **The picture comes from the BACKEND.** `client.drawing(file)` is\n  `GET /__cad/drawing` (`apps/web/docs/backend.md`), asked again while the server\n  renders a drawing it has not drawn before: ezdxf flattens the modelspace on the\n  server — text placed, dimensions exploded, hatches filled, blocks placed — and the\n  client receives five primitive shapes and `text` in DXF coordinates, y up. **This\n  renderer never parses DXF.** The payload is cached server-side by the document's\n  content hash, so reopening a file costs a round trip and nothing else.\n- **Drawing is core's** (`@text-to-cad/core/lib/drawing2d`), so the headless snapshot\n  bundle paints the same picture from the same payload: `fitTransform` / `zoomTransform`\n  / `panTransform` (one uniform scale and a translation; the y flip lives in the\n  transform, not in the geometry), `prepareDrawing(payload)` (paths built ONCE, per\n  colour for strokes and per primitive for fills; text kept as strings) and\n  `drawDrawing(ctx, drawable, …)` (text set with `fillText`, stretched to the width the\n  server measured, so the page's face keeps the drawing's layout).\n  That bundle IS `cadgen dxf snapshot`: cadgen resolves a `.dxf` to the same\n  `cadgen.drawing_payload` this route answers with and the page fits and paints it\n  (`common/headlessDrawingRender.js`), so a CLI render cannot show what this pane\n  cannot. The CLI's option surface was cut to match — no display, no render mode,\n  `--appearance light|dark` and a size; a job may add a plan-view `camera` and an\n  `annotation` on the sheet, what a Quick Edit's views carry.\n- **Hairlines, always.** Strokes are 1.25 CSS px at every zoom, as AutoCAD draws with\n  LWDISPLAY off: paths are in MODEL space and the context carries the view, so one\n  `lineWidth = 1.25 / scale` per frame rebuilds no geometry. Model-space lineweights\n  are not displayed at all.\n- **`color: null` is the default pen** (ACI 7, \"whatever contrasts with the\n  background\"), resolved at DRAW time against the app's `--foreground` on its\n  `--background` — the same pair the 3D viewers' chrome uses. One payload therefore\n  serves both themes: flipping `.dark` repaints, it does not refetch. The tokens are\n  read off the pane, and a mutation on `<html>` schedules a frame. When they cannot be\n  read — and in the snapshot bundle, which has no stylesheet at all — the pair comes\n  from `@text-to-cad/core/lib/appTheme.js`, the one place both colours are written down.\n- **Fills are even-odd, and each one is filled on its own.** A `filled-paths`\n  primitive's inner rings are its holes; merging two overlapping regions of one colour\n  into a single path would turn their overlap into a hole as well. Fills go down\n  before strokes, which is the one ordering that never hides an edge.\n- **Interaction** (`dxf/useDrawingView.js`): fit on open and on resize until the\n  person moves the view, drag to pan (a primary press, one finger, once it has travelled\n  more than a tap's 4 px; a press that lets go before is the tool's tap), wheel or pinch to\n  zoom about the pointer, double-click to fit again. The view lives in a REF and the\n  canvas repaints through one `requestAnimationFrame` when something changed; a pan\n  never re-renders the component tree. A resize paints at once, inside the pane's\n  ResizeObserver (after layout, before paint): resizing the canvas wipes it, and a frame\n  asked for there would leave the pane empty for one frame\n  (`dxf/DxfResize.browser.test.mjs`). The backing store is DPR-aware\n  (`kit/viewport/pixelRatio.js`). What the tools draw (highlights, ink) is painted in\n  the same frame, after the drawing, so a capture holds it.\n- **Navbar**: nothing of the drawing's — no zoom buttons, because zooming a drawing is\n  the pointer's: wheel or pinch about it, drag to pan, double-click to fit. The host's\n  `captureRequest` is the canvas as a PNG, background included, delivered through\n  `host.promptContext`.\n- **Empty and failed drawings.** `bounds: null` (nothing in the modelspace) is a quiet\n  sentence over the empty pane, not an error. A non-200 becomes the ordinary actionable\n  alert card (`ViewerAlertCard`) carrying the SERVER's sentence (`failureAlert`,\n  `kind: \"http\"`); a payload from\n  a cadgen that disagrees about `schemaVersion` gets its own alert whose recovery is to\n  update cadgen and the app together.\n- **Host commands**: `resetCamera` fits the drawing again, `capture` hands over the\n  PNG, `clearSelection` lets go of the picks, and `readState` reports `camera: null`,\n  an empty `display`, the selection as references \n\n<!-- [TRUNCATED AT 50KB] -->","packages/ui/docs/feature-detection.md":"# Client-side STEP feature detection\n\nFeature detection helps people navigate and select geometry in the shared Model\ntree. It belongs to the STEP renderer in `@text-to-cad/ui`; web and desktop run the\nsame code. It reads saved geometry through the injected CAD resource service.\nIt never reads model source, writes artifacts or executes a CAD kernel.\n\nKeep recognition independent of cadgen compilation, Python inspection and the\nreference grammar. A recognized feature is a group of existing canonical faces\nand edges, not a new kind of reference. Selecting or copying it uses the normal\noccurrence-scoped reference and prompt-delivery flow. Feature labels and inferred\noperations are not stable identities across document edits or recovered source\nhistory. Recognition is optional: structural rows, rendering and part selection\nmust work without it.\n\n## Demand and cancellation\n\n1. Load the lightweight assembly descriptor to display the hierarchy.\n2. The Model tree requests recognition for expanded, visible parts. Collapsed\n   parts need no recognition or exact-surface request just to show their rows.\n3. Resolve each requested occurrence to its component. Repeated instances share\n   component analysis while retaining distinct selection IDs and placement.\n4. Reuse completed recognition metadata when its exact geometry identity matches.\n   Otherwise obtain the component's SURF data through the resource provider and\n   analyze it in a disposable worker.\n5. Publish the result, cache successful metadata, then process the next requested\n   component. Each mounted inspector owns at most one active recognition worker.\n\nChanging expansion updates the queue without restarting an active component that\nis still requested. Switching between occurrences of that same component also\nkeeps its work. Removing the last requested occurrence cancels both pending\nsurface loading and worker work; queued components that are no longer requested\nare skipped. Taking the model tree off screen (choosing another tool, or entering\npreview), changing geometry or resource scope, and\nunmounting cancel the entire inspector's pending work. Cancelled jobs cannot\npublish late results or populate the completed cache.\n\nThis is deliberately a sequential queue per inspector, not a global worker pool.\nCompleted results are reusable between mounts; pending jobs are not shared\nbetween separate inspectors. Retry clears failed results and queues those\ncomponents again without cancelling other components that are still needed.\n\nLoading is lazy **per component**: expanding a part analyzes that component as a\nwhole, not only the feature rows subsequently opened. Recognition neither expands\nother parts nor blocks the initial structural tree. The stable top row counts\npresented top-level features; it does not display global recognition progress.\nSelection, isolation and reveal rules remain in the\n[Model tree contract](cad-renderer.md#step-panels).\n\n## Cache identity and lifetime\n\nThe completed cache key combines:\n\n- The resource provider's workspace/generation scope.\n- `MODELING_RECOGNITION_VERSION`.\n- Exact surface input and object identities, or a provider-scoped immutable SURF\n  URL for older static packages.\n\nDo not key recognition by filename, display label or assembly occurrence. On a\nwarm reopen, accepted component identities can avoid surface requests altogether,\nbut only after workspace, provider generation, entry revision and runtime descriptor\nmatch. Changed geometry or scope must never reuse the previous binding.\n\nThe least-recently-used cache holds at most **512 components / 8 MiB** of accounted\nserialized metadata. Each read returns a private copy. It retains no SURF buffers,\nworkers, scene objects, pending promises or failed results. The active inspector\nalso retains its own results, including errors for explicit retry; disposing it\nreleases that local state. Large or evicted results may require recomputation.\n\nThis cache is **in memory**, separate from cadgen's persistent surface/mesh store\nand host preference storage. Refreshing the page or restarting the app loses it.\nThere is no recognition sidecar, disk database, compile step or backend endpoint.\n\nAdvance `MODELING_RECOGNITION_VERSION` in\n[modelingRecognitionCache.js](../src/renderers/step/workbench/modelingRecognitionCache.js)\nwhen inference rules, tolerances or serialized results change, including returned\nlabels, measurements and face/edge grouping. This is an internal algorithm\nrevision, unrelated to the repository's release version. Presentation-only\nchanges outside cached results do not require a bump. Geometry and resource\nrevisions remain independent parts of the key; the algorithm version replaces\nneither. If configurable recognition options are introduced, their semantics\nmust also participate in the key.\n\n## What the recognizer infers\n\nThe current algorithm first tries a complete extrusion, revolution or supported\nsolid reconstruction. Otherwise it presents an imported body with local feature\ncandidates and remaining geometry. That precedence is a presentation heuristic:\nthe same circular cut can appear as a cut extrusion or a bore in different\nbodies. Changing these classifications is separate from cache/lifetime work.\n\n- **Extrusions:** paired analytic cap contours, straight walls, side area and\n  solid volume support a possible base/cut sequence. Profiles select real STEP\n  boundary edges.\n- **Revolutions:** full coaxial cylinder/cone solids can yield a closed axial\n  profile and 360-degree revolution checked against solid volume.\n- **Pockets and bosses:** local planar caps, translated rims and straight walls\n  identify supported prismatic regions. Current local tests cover convex analytic\n  profiles; they do not cover every pocket or boss.\n- **Bores:** adjacent cylindrical patches are joined across export seams.\n  Annular shoulders and full conical transitions can connect stages into blind,\n  countersunk or counterbore candidates. Boundary samples and enclosed face\n  bounds reject evident intrusions; they do not prove an empty volume.\n- **Blends and other supported solids:** connected constant-radius\n  cylindrical/toroidal blends, rounded boxes and supported ruled lofts have\n  analytic recognition paths. Single curved corners are not automatically\n  separate fillet operations. Shells and patterns are not invented.\n\nUnclaimed faces remain available as other geometry. Candidate ordering is not a\nmodeling timeline, and a missing candidate does not establish that a feature is\nabsent. Measurements describe supported geometry; recognition is not engineering\nvalidation. Independent kernel reconstruction checks belong to development tests,\nnot the runtime viewer or Python inspection API. Complete reconstruction claims\nrequire both directional Boolean differences to agree; finding a local candidate\nor matching its tool volume alone does not establish a rebuildable whole part.\n\n## Limits and failures\n\nThe worker ticket permits at most 16 MiB of SURF data. Analysis rejects inputs\nover 6,000 faces or 20,000 edges; the worker/ticket stage has a 10-second timeout.\nThese are safety limits, not promised execution times. Surface derivation before\nworker startup has its own resource-service lifecycle.\n\nUnavailable data, unsupported-size inputs, worker errors and timeouts produce a\ncomponent error and explicit retry. They do not become reusable cached results.\nOther requested components can still be processed. Existing ordinary geometry\nand part selection remain available. Keep workers off the UI thread, preserve\nthese bounds, and profile large parts before adding persistent caching or more\nconcurrency.\n\n## Implementation and verification\n\n- [useModelingRecognition](../src/renderers/step/workbench/useModelingRecognition.js):\n  descriptor demand, per-inspector queue, resource requests and cancellation.\n- [modelingTree.worker](../src/renderers/step/workbench/modelingTree.worker.js):\n  resource-ticket loading, SURF parsing and inference.\n- [modelingTree](../src/renderers/step/workbench/modelingTree.js) and adjacent\n  `modeling*` helpers: pure inference and canonical reference mapping.\n- [modelingPresentation](../src/renderers/step/workbench/modelingPresentation.js) and\n  [ModelingTree](../src/renderers/step/components/workbench/ModelingTree.jsx):\n  occurrence binding, presentation, expansion and selection.\n\nKeep renderer-private pure helpers here; they do not need to move to core merely\nbecause they do not import React. Hosts implement resource effects through the\n[viewer host contract](viewer-host.md), never a second recognizer.\n\nRun `npm --prefix packages/ui test` from the repo root for helper and React\ncoverage. Recognition tests cover real STEP-derived fixtures, version/geometry\ninvalidation, cache bounds and warm reopening, expansion during pending work,\nrepeated instances, retry, disposal and rejected late results. Fixture provenance\nlives in the [fixture guide](../src/renderers/step/workbench/__tests__/fixtures/README.md).\nAdd small test-owned regression fixtures for changed inference rules. Rebuild\npackage exports and the bundled viewer before testing either app.\n","packages/ui/docs/file-viewer.md":"# FileViewer\n\n`@text-to-cad/ui/file-viewer` exports the complete file view: one file by its\nabsolute path, under the navbar, with the explorer its name opens, renderer loading,\nand the loading and error pages.\nIt imports no concrete renderer. Applications compose registrations from the\nseparate `@text-to-cad/ui/renderers/*` entry points and their own\n`defineFileRenderer` definitions ([renderers](renderers.md)). A CAD host composes\n`CadViewer` (`@text-to-cad/ui/cad-viewer`), which is this component with the five\nCAD renderers, the home and the standard error pages ([viewer host](viewer-host.md)).\n\n```tsx\n<FileViewer\n  file={selectedPath}   // absolute; null is the host's home\n  host={viewerHost}\n  renderers={registeredRenderers}\n  state={viewState}\n  onStateChange={persistViewState}\n/>\n```\n\nSee [ViewerHost](viewer-host.md) for required host services and prompt delivery.\nCreate sources and registration arrays at their owning workspace or tab\nlifetime. Changing their object identity cancels outstanding document work.\n`source.id` is a stable identity, independent of a server's temporary port. Every\npath is absolute and `/`-separated (`/a/b.step`, `C:/a/b.step`); containment and\nauthorization remain in the host service. The host controls navigation\n(`navigation.openFile(path)` where the view can show another file, and `home()` where it\nhas a home; a view that shows one file alone has neither, and no explorer) and keeps\n`FileViewerState` in its tab store (`@text-to-cad/ui/tab-store`; `useTabViewerState`\nderives it): `renderers`, under encoded `[file path, renderer ID]` keys, each the\nfile's view.\n\nA source reads files and never writes them. `stat(path)` answers a file's metadata;\nwhere its files can be browsed, `list(directory)` answers a folder's entries and\n`search(directory, query)` the files under a folder whose path below it holds the\nquery, saying when it stopped early. What the file menu offers is the host's\nseparate `fileActions` capabilities. Sources receive an `AbortSignal` for each read;\nnoncancellable reads still check the signal before returning. A renderer that\nacquires a resource (a URL lease, a worker) returns its release as the prepared\ndocument's `dispose`; FileViewer calls it on navigation, reload, cancellation and\nunmount.\n\nUse `defineFileRenderer<T>` to keep the prepared payload paired with its lazy\ncomponent. A definition supplies `id`, numeric `priority`, `matches`, `prepare`,\nand `load`, and optionally `fallback`. Larger priorities win.\nDuplicate IDs and equal-priority matches\nproduce explicit errors. At most one registration may declare `fallback: true`;\nit runs only when no ordinary registration matches. Registration construction\nand matching do not load the component.\n\n`prepare` returns `{ data, dispose? }`. FileViewer opens a file's document when the\nfile is shown, and again only on `reload`: a renderer follows its file's changes\nitself (every CAD renderer loads the next revision behind what is on screen). A CAD\nfile's controls are its own, over the viewport: its tool strip, the tool in hand's\nsub-toolbar, and its file sidebar\n([the design system](settings-ui.md#the-file-sidebar-and-the-tool-stack)); the view's\ncontrols (Display, Preview) are the navbar's, last at its right end.\n\nBelow 600px of its own width FileViewer is mobile (`useViewerMobile`). The width is a\nbreakpoint boolean, never a pixel value, so a resize within one layout does not\nre-render the renderer.\n\nA renderer is handed, besides its document: `onNavigationActionsChange`, for its\nnav-row actions (`FileNavigationAction`, with an optional shorter `hint`), hidden\nwhile it reports `onReady(false)`; `appearance`; and `onOpenFile(path)`, which shows another file, by\nits absolute path, in this view (a robot's mesh), absent where the view shows its file alone. Its loading and update status are its\nown to show: a CAD renderer shows them in its viewport. Host-specific pages are supplied through `presentation`,\nwithout duplicating the view's placement: `home` is a host's own page for a view with\nno file (every CAD host's model library), under the navbar; `loading`; and\n`error({ message, missing })` for a file that will not open, `missing` when it does\nnot exist. Per-file renderer state is also accepted during a departing renderer's\ncleanup, while it still belongs to the same source.\n\n## The navbar and the explorer\n\nThe navbar is drawn over every file from the moment one is asked for, and over the\nhost's home — only its logo and menu, without Back to files, Back and Forward where the\nhost keeps the view's history, and the host's update and Full size, where they stand over\na file — never for a `compact` view (a\npicture: the home's thumbnails); the CAD viewer's Preview keeps it. Over a file, left to right:\n\n- the text-to-cad \"C\" logo (named Menu, with no chevron), which opens the app menu\n  (`navigation/AppMenu.jsx`): Back to files where the host has a home\n  (`navigation.home`), the person's settings (`appSettings`), Send feedback, GitHub,\n  Discord and, in gray, the version and who made it. FileViewer builds it from\n  `host.links`, `appSettings` and `environment.platform`. A host with no home has the\n  logo and the menu too, without Back to files;\n- Back and Forward, where the host keeps the view's own history (`history`, a\n  `ViewerHistory`: the CAD app's views; a browser's page has its own and hands none): two\n  arrows, each there always and disabled where it has nowhere to go;\n- the open file's name: a button that opens the explorer where the source can both list\n  and search, and plain text otherwise;\n- the ⋯ file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host's `fileActions` can do it, and no ⋯ where it can do\n  none.\n\nThe logo, Back and Forward, the name (where it opens the explorer) and the ⋯ are one kind of control: transparent\nat rest, the same accent background while the pointer is on it and while its menu is open, a\npointer cursor, and no tooltip.\n\nAt the right, only what a host or a file adds: the host's update button, a renderer's\nactions and the host's Full size, where the host shows the view small. A renderer's\nown view controls (a 3D view's Display and Preview, or Preview's settings and Exit preview)\ngo last, in the box FileViewer hands it (`navbarSlot`, `data-view-controls`).\n\nThe explorer (`navigation/FolderExplorer.jsx`) is a popover under the file's name, a\nfixed 18rem wide and never resized. It floats over the view, so opening it resizes\nnothing, and it is built anew on each opening, so it starts in the open file's\nfolder, its filter taking the keyboard (on a phone the explorer holds focus itself, so no\nkeyboard comes up). It lists one folder at a time through `source.list`: the folder's\nsubfolders, then its CAD files, the open file highlighted, in compact rows (a folder's glyph\nits disclosure chevron). A press on a subfolder opens it inline, under itself and a little\nfurther in, reading it through `source.list` the first time; a double-click opens it in the\nexplorer's place. A breadcrumb shows as many of\nthe last three folders of the one it is in as fit whole, measured before it is painted:\na crumb is never cut, the farthest giving way first to a \"…\" button whose menu lists the\nfolders above the crumbs to jump to, and only the folder it is in, alone beside the \"…\",\nis cut to the row. A crumb, as a double-click does, opens a folder in place, and there is\nno up arrow. \"Filter files...\" searches every file nested under the folder it is\nin through `source.search`: the client waits 150 ms after the last keystroke and\naborts the search before it, and each match shows its path below the folder, the\nsubfolders muted before the name. An answer that is `truncated` adds \"Stopped early.\nKeep typing to narrow the search.\" The viewer server bounds both reads\n(`GET /__cad/folder`, `/__cad/search`): hidden and build folders are left out of each,\nand a search stops at 200 results, about 1.5 s or depth 24, walking a folder it reaches\ntwice once. A pick opens the file through `navigation.openFile` and closes the popover.\nUp and Down move between the filter and the rows; Escape closes it like any popover.\n\nThe browser harness beside FileViewer exercises injected renderers, source and file\nchanges, navigation actions, the explorer and the file menu, the mobile breakpoint,\nand resource disposal. Run it with the package test command after building the shared\npackages. Chromium must be available for the browser tests.\n","packages/ui/docs/lod.md":"# Level of detail, memory, and live updates\n\nWhat the viewport does in the background between \"a file was selected\" and\n\"the picture has settled\": which tessellation it starts from, how it refines,\nwhat it refuses under memory pressure, and how it follows a model that is\nbeing rebuilt while you watch.\n\nAll of it is the STEP package path of the STEP renderer. A triangle mesh (STL, 3MF) and a\nGLB have no levels: their renderers load the file whole, parse it once per revision (an\nSTL in a worker) and keep the decode cached, so reopening one fetches and parses nothing\n(see [Mesh renderer](cad-renderer.md#mesh-renderer)).\n\nThe observable promises are in [the web app README](../../../apps/web/README.md#the-laws-that-bind-the-app);\nthis file is the mechanism behind them. None of it may change **exact**\ngeometry, measurements or explicit mesh-export tolerances — detail is a\ndisplay concern only.\n\n| § | Covers |\n|---|---|\n| [1](#1-where-a-model-starts) | Coarse-first admission, cached standard meshes |\n| [2](#2-what-refinement-samples) | Camera sampling, offscreen components, hysteresis |\n| [3](#3-admission-and-memory-pressure) | Budgets, reservations, coarsening caps |\n| [4](#4-the-replacement-batch) | CID caps, the first-ready deadline, adoption receipts |\n| [5](#5-cancellation-failure-and-cleanup) | Parked targets, restoration, ownership |\n| [6](#6-following-an-active-build) | The edit feed, what the viewport shows, the status heartbeat |\n\n## 1. Where a model starts\n\nAssemblies with at least 64 unique components can start at a coarser display\ntessellation when standard meshes are not cached. Cached standard meshes are\npreferred immediately, subject to their probed decode size and admission. The\ntiers are probed a chunk of components at a time and the cached bodies read a\nbatch at a time (`createInitialDisplayPlans`, `packageBatchReads.js`), the first\nof each the size of the first publish, so the first geometry waits on no more\nthan it draws.\nSmaller assemblies start at the standard level, except that an individually\noversized component may start coarse. A component above the concurrent decode\ncap runs alone only when the shared Viewer memory envelope can reserve its\ncomplete estimate.\n\nCoarse geometry is a temporary preview: visible components automatically reach\nat least the standard level, preserving its angular smoothness even when\nprojected chord error alone would permit a coarser mesh. Close inspection can\nrequest finer detail, and so does preview, which the STEP renderer draws one\nscene-quality tier up (`kit/viewport/previewViewScene.js`) without changing the Display\nsetting: while Preview is open the loader samples Preview's camera and asks at its\ntier, both scenes reconcile the same published mesh, and only the editor's adopts it. The chrome does not report detail levels: the STEP renderer\nrecords first geometry and standard detail for benchmark harnesses only\n(`window.__cadViewerQuality`, `useViewportQualityStatus`), and background file\nwriting stays quiet. That record lives in the STEP surface, so it takes a scheduler\nsnapshot only when the status it derives would change (`lodSnapshotStatusKey`): a\npreview orbit resamples the camera on every frame, and a snapshot per frame would\nre-render the whole surface per frame — while a model streams in, redoing the\npartial model's derived state each time.\n\n## 2. What refinement samples\n\nRefinement uses the camera and the disposable memory budget.\n\nStatic assemblies sample full transformed occurrence bounds against the camera\nfrustum, refining a component when at least one occurrence is on screen.\nOffscreen components stay displayed: ordinary camera sampling retains their\nexisting detail, and memory pressure can coarsen them before visible\ncomponents. Unknown or not-yet-adopted bounds remain eligible.\n\nA stationary camera requests the final level implied by the existing hysteresis\nthresholds directly. If admission refuses that level, strictly intermediate\nlevels can supply measured replacement sizes for another try.\n\n**Resampling a motionless viewport is not work.** A sample carrying the same\ncamera, viewport and per-component distances, visibility and selection neither\nrestarts the settle debounce nor republishes status, so a viewport nobody is\ntouching reaches settled quality and stays there. An idle scheduler reports\nmemory-limited targets separately from settled quality. Camera and distance\ncomparisons ignore floating-point noise at a 1e-10 relative tolerance, measured\nagainst the last meaningful sample so accumulated movement still triggers work.\nVisibility, selection, quality changes and explicit retries remain meaningful.\n\nScenes with joints, embedded animation, drawing poses or an active/collapsing\nexploded view keep conservative eligibility, including paused or disabled pose\ncapabilities. Authored visibility and material flags are not LOD filters.\n\n## 3. Admission and memory pressure\n\nFailed loads stay parked; denied admission retries only after the displayed\nlevel or camera intent changes. Pressure-driven coarsening caps subsequent\nrefinement until the camera or viewport changes, preventing upgrade/downgrade\nloops. Mesh-bound and clip-plane updates do not reset that cap. Pressure\ncoarsening remains singleton.\n\nAdmission can reclaim idle tessellation workers and retry while preserving\nactive consumers. Its ledger samples each live worker's own retained estimate\nbefore admission, so a large component does not inflate every worker's charge.\nRefinement reserves both replacement arrays and worker scratch space, and\nincludes the coarse tier's relaxed angular tolerance in its estimate.\n\nDisplay arrays shared with asset caches have one CPU charge for the entire\nbacking allocation, including unused sections of packed buffers. GPU charges\nuse uploaded view sizes; CPU-only edge inputs and picking allocations are\naccounted for separately.\n\nA component that cannot fit even at the coarse level reports a limitation and\npreserves the current view. **Estimates and sampled resource totals are a soft\nbudget, not a hard browser RSS limit.**\n\n## 4. The replacement batch\n\nThe scheduler holds at most four distinct replacement CIDs across loading,\nready payloads and actual scene adoption. Its render and late-selector\npreparation share one loader lane, and only one atomic mesh/reference\npublication awaits adoption.\n\nThe Viewer uses a 128 ms first-ready collection deadline so serialized cached\nreads can fill the four-component batch. It may publish a ready subset beside\none unfinished carryover; it does not guarantee selector, worker or scene\nreadiness. No fifth replacement starts. Admitted refinements keep filling the\nbatch while exact sibling reservations allow it, even after the coarse pressure\nthreshold is crossed; a denied reservation flushes the ready subset.\n\nSeparate user-driven topology requests keep their existing worker admission and\ncache/picking accounting; they are not included in the scheduler's occupied-CID\ncount. Topology-only interactions also release idle workers after their sibling\nrequests drain.\n\nActual payload backing allocations are reconciled before another sibling is\nadmitted. Temporary sibling-capacity denials flush and retry after ownership\nchanges; they do not permanently park a target. Displayed levels and measured\ncurrent sizes remain unchanged until the complete exact batch adopts.\n\n**Adoption receipts.** Replacement admission stays held until the viewer adopts\neach current component payload at every occurrence and accounts for its scene\nownership. This acknowledgment schedules rendering; it is not a GPU\nupload-completion fence, and modeled upload ownership remains separate. A\nsuperseding progressive publication can satisfy it only with the same context,\nrevision, occurrence set and exact payload. Display geometry and demanded\nselectors publish as one matching state pair, with commit receipts fencing\nabandoned or replayed React updates.\n\nEvery publication lands in the ONE STEP scene the viewport adopted\n(`renderers/step/scene/stepScene.js`): while the structural build key holds for\nthe same model, the live build reconciles its records in place, and the viewport\nis told the scene changed (`viewport.commitScene()`) rather than handed a new\nscene — so a progressive open or a detail swap never re-dresses every material or\nre-adopts anything. The camera frames the model once, on its first publish, on\nthe box `assembly.json` declares for the whole of it (`bbox`, carried as the\ncomposition's `declaredBounds`): the scene's `restBounds` is that box from the\nstart, so the framing, the orbit pivot, the zoom ruler, Zoom to fit, preview's\nturntable and the ground's size are final before most components have arrived,\nand later publishes move none of them. A descriptor without a box leaves the\nscene `complete: false` until the last component is in: it is framed on what\narrived first and once more when it is whole, unless the person has taken the\ncamera by then. The camera sample that drives all of this is taken when the\nviewport says the camera settled (`onCameraSettled`: a move, a preview orbit, or\na resize, which can expose a part without moving the camera) and when the\nselection changes.\n\nA static component publication can reuse the main adoption's completed reset\nonly in that same React render. Later visual or clipping changes still run\nnormally, as do transitions out of modules, animation, drawings or poses.\n\nProgressive display and later detail swaps share unchanged occurrence rows and\ntree metadata; changing tessellation alone does not rebuild every tree leaf.\nPlacement, appearance and changed bounds still update their records. Selection\npruning preserves unchanged selected, referenced and hidden ID arrays,\npreventing detail publications from retaining historical workspace render\ncontexts through unnecessary selection updates.\n\nDisplay raycast accelerators are requested only when a picking ray reaches\ncomponent bounds, then queued during idle time for one worker at a time.\nAdmission covers private input copies, worker scratch and the returned tree;\ndisplayed arrays stay attached and unchanged. Releasing the last geometry owner\ncancels its build, and stale results cannot attach to replacement geometry. The\nfirst pick remains exact and may cost more on a dense component; merely loading\nor refining an assembly does not build an accelerator for every component.\nInputs with a separate merged face-selection proxy still build that proxy's\naccelerator on the main thread during idle time. Canonical STEP selectors use\nthe display meshes and do not enter that separate path.\n\nDiagnostic snapshots identify scheduler-only ownership, batch sizes and seal\nreasons. The internal size-one control uses the same admission/publication path\nas groups of four.\n\n## 5. Cancellation, failure and cleanup\n\nCancellation requests cleanup: switch, abort or unmount retains an outstanding\nreservation until actual replacement, restoration or complete disposal proves\nthat the renderer has released its previous owner. Pending component maps\nremain separate from adopted maps.\n\nA failed scene update clears its partial records before rebuilding the last\nadopted mesh/selector pair; it never reconciles against already-disposed\nrecords. Restoration preserves unrelated progressive components and completed\nselector loads. A second construction failure stops detail work and reports an\nerror. Cleanup failure keeps ownership charged until a real cleanup retry\nsucceeds.\n\nA cancelled batch that actually adopted remains a displayed payload owner even\nthough its scheduler levels are not promoted, so cancellation does not evict\nits exact cache entries.\n\n## 6. Following an active build\n\nSTEP entries always follow active edits, showing the root preview before its\nSTEP save. Run the model normally; existing decorators need no new imports, and\nthe daemon must be running for live updates.\n\n**The feed.** Updates arrive through a held request that wakes when this\noutput's build ledger changes. Unrelated jobs do not wake the tab. The server\nadmits 32 waiters independently of kernel workers; excess tabs retry every\n500 ms. A server that answers at once instead (a host relaying requests through a\nfew slots all its views share, where a held request would take one) is paced by the feed: an answer\nwith nothing new waits out the rest of a second, and news is asked after again\nwithin a tenth of one. An idle heartbeat revalidates saved bytes and missing geometry; closing\nor switching the tab cancels the request. Older status responses cannot\noverwrite newer cached progress, and saved revisions are verified from one\ncoherent file snapshot. Restarting the daemon expires the ephemeral session,\nand rerunning the model reconnects it.\n\n**What stays on screen.** The prior model stays visible while the next request\nbuilds; failed updates remain visible while an idle disconnected feed retries\nquietly. A complete model of the same file — a part's or an assembly's — also\nremains visible while its rewritten file is rebuilt and while the replacement\nmeshes load (`replacingSameFileMesh`, `awaitingSameFileRevision`), reported as an\nupdate (\"Updating model…\"), never as the loading screen — also when edits come\nfaster than revisions load, and one revision's load is cancelled for the next\n(`meshStateAfterCancelledLoad`). Complete displayed component arrays remain available while a\nreplacement stages or fails. Reuse requires the same runtime surface input,\nconcrete surface object and tessellation; placements and appearance come from\nthe new tree. Selection, measurements and reference copying wait for matching\nnew geometry. A failed replacement preserves the view and reports its error;\nonly that file/hash stops retrying automatically. A first load that fails part\nway keeps the parts it drew under its error, and refinement goes on over them: a\ndetail swap changes geometry only, never whether the load finished or failed\n(`detailSwapMeshState`). STEP pose and animation\nmetadata use their normal loading path, without a promise to retain the\nprevious pose. Snapshot source isolation is unchanged.\n\n**Overlapping loads.** Geometry and reference loads own their cancellation\nindependently; a superseded request cannot cancel its replacement. Topology\nrequests for one file revision union rather than supersede: a batch in flight\nfinishes, and one no longer wanted is simply not published.\n\n**After a save.** The view shows the saved file. When a build writes it, the new\nrevision replaces the one on screen in place, keeping every component whose identity\nit shares; while the build runs the model on screen says it is updating, and a failed\nbuild leaves it on screen under its alert.\n\n**What the viewport shows.** The navbar carries no status. Opening and\nupdating are the viewport's loading overlay (`ViewerLoadingOverlay`). A failure is\na card over the viewport (`kit/status/ViewerAlertCard.jsx`) with its explanation,\nits next step and the full diagnostic under Details: an error always shows, and a\nfailed update the model survives (`blocking: false`, the previous version still on\nscreen) has a Dismiss button and stays dismissed until the alert changes, or\nclears and is raised again. A warning is a card too, one that can be dismissed\nwhile the model is on screen: the card is the one place a problem is said. Once a\nusable current view is displayed, saving, successful completion, idle edit-feed\nstate and routine refinement stay quiet. A sidecar this build cannot read raises\nno alert: the model renders with no kinematics, no materials and no routine, and\nthe migration is announced where it can be acted on — the build and the cad\nskill. Existing usable views remain visible during updates and failures.\n\n**Opening.** Opening shows one step line — **Finding file**, **Reading\nmodel**, **Loading geometry**, or **Preparing view** — with no headline above\nit; the loading mark itself says a model is opening. Counts measure\ncompleted geometry items in the current stage, not assembly occurrences or an\noverall ETA; uncounted stages are indeterminate. Render initialization uses the\nsame indicator against the destination backdrop until its first usable frame.\nLong waits show elapsed time; interrupted progress explains that the viewer is\nwaiting for a response before offering recovery. Selection and edge preparation\nreport beside their controls, not as whole-model loading.\n","packages/ui/docs/render-mode.md":"# Display modes\n\nThe Viewer and snapshots share one strict sparse contract. Core's `common/viewSettings.js`\nvalidates it and resolves the mode, or Preview's Render, into renderer groups.\n`common/sceneSettings.js` translates those groups into the scene recipe. The binding\ncontrols are in [settings-ui.md](settings-ui.md#display-settings-and-section-primitives).\n\nA file's display is a mode and the Grid switch. A mode is a look at its own defaults, with\nnothing to set. Render is no display mode: it is the photographic look Preview and a\nsnapshot's `--preview` ask for on their own, over the same display, with Preview's own\nsettings (the viewer's alone, kept beside the display).\n\n| Mode | Surface / edge recipe |\n| --- | --- |\n| Solid (default) | Shaded, the model's visible CAD edges |\n| Shaded | Shaded, no edges |\n| Parts | Solid, each part its own palette colour |\n| X-ray | 22% opacity, all edges |\n| Hidden line | Depth-only surfaces, visible edges (light on a dark backdrop) |\n| Wireframe | No fill, all edges (light on a dark backdrop) |\n| Render (Preview's, asked for on its own) | Shaded, no edges, no grid, photographic studio |\n\nA file without CAD edges (STL, 3MF, GLB, a robot) offers Solid, drawn without edges, and\nParts where it has several parts, objects or links; Shaded would be Solid again there. The\ngrid is off by default and the same measuring grid in every mode. Appearance, camera, Clip\nand Explode are shared. Camera defaults to orthographic with a 50mm lens; Render resolves to\nperspective without overwriting that shared camera choice.\n\n## Sparse public contract\n\n```json\n{ \"mode\": \"parts\", \"grid\": true, \"appearance\": \"light\", \"camera\": { \"projection\": \"orthographic\" } }\n```\n\nOmitted mode is Solid, and `mode` is one of the six; `grid` is a boolean, off unless said.\nThe display also takes `clip` and `exploded`. Anything else is refused: there are no\nper-mode settings, style groups or axes. Preview's own settings (`parts`, `lighting`,\n`background`, `floor`) are a separate sparse object (`normalizePreviewSettings`): `parts`\ncontains `colorMode` (`original`, `single`, `by-part`), `color` and `colors`; `lighting`\ncontains `quality`, `exposure`, `rotation`, `size`, `fill`; `background` contains `color`,\n`opacity`; `floor` contains `enabled`, `placement`, `color`, `opacity`, `finish`.\n\nViewer appearance inherits the host; snapshots default to light. Lighting quality inherits\nPreview in a viewer and Final in a snapshot. There is no Custom state, legacy alias or\nmigration. A stored display or Preview settings record is read setting by setting at the\nfile-session read boundary (`readViewSettings`, `readPreviewSettings`): what this build\ncannot read (an older per-mode tree, a mode it no longer has) is left out, the rest kept.\nThe same display shape powers live `setDisplaySettings` and snapshot `--display`; either\nrefuses `mode: \"render\"` as it refuses any mode it does not know. A snapshot turns the grid\non with `--grid` (`display.grid`) and asks for Preview's look with `--preview` (a job's\n`\"preview\": true`, the page's own flag), at Preview's defaults. `--camera` holds\npose/framing only. A Quick Edit's views carry the mode and the grid (`display.mode`,\n`display.grid`) with Clip and Explode, so a snapshot of them replays the view.\n\n## State and updates\n\n`kit/view-settings/viewSettingsStore.js` owns one sparse display, and Preview's settings,\nper mounted file. `selectMode(mode)` selects a mode; `setGrid(on)` switches the grid (off\nis left unsaid); `patch(patch)` merges the display's shared fields. `patchPreview(patch)`\nmerges Preview's settings and `resetPreview()` removes them. `restore(display, preview)`\nreplaces state once on mount; persistence reads the latest `getSnapshot().display` and\n`.preview`. `renderScene()` resolves Preview's scene without committing anything.\n\nNo action reframes the camera, changes model pose, selection or visibility. The store\npreserves equal JSON branches; a Preview edit leaves the display's scene identity unchanged.\nClip edits do not invalidate materials, lighting, geometry or camera. No-op actions publish\nnothing. Do not merge captured props, persist resolved defaults or keep a second display\nstate.\n\nThe view injects its offered `modes` and `settings` (`edges`, `clip`, `exploded`). Unsupported\nsaved modes resolve to Solid without rewriting storage; unoffered settings do not affect the\nscene. The shared UI discovers no host APIs.\n\n## Rendering costs and camera behavior\n\nThe photographic rig stays lazy. Preview's Render creates its photographic\nsoftbox environment; the other modes use neutral CAD lighting. Authored materials are read-only. Color\noverrides are presentation settings, not edits to the model or its material\nassignments.\n\n| Lighting quality | Screen error | Shadow map | Environment | Capture scale | Samples at rest |\n| --- | --- | --- | --- | --- | --- |\n| Preview | 1 px | 2048 px | 256 px | 1× | 32 |\n| Final | 0.25 px | 4096 px | 512 px | 2× | 64 |\n\nRender is drawn through the studio's frame (core's `studioFrame.js`, see\n[the render pipeline](../../core/docs/render-pipeline.md#the-studios-frame)): ambient\nocclusion from the frame's depth, and, while the view rests, a picture that converges over\nits samples: anti-aliased, its occlusion smooth, the key's shadows a softbox's (Softbox size\nsets how soft). The viewport's loop (`useViewerRuntime`) draws each frame through it and says\nwhether anything but the camera changed (`runtime.requestRender`/`requestFrame` say so); while\nsamples are owed it asks for the next frame itself, and once the picture has converged it\ndraws nothing more. A drag, a routine's frame or a setting starts the picture again; the\ndamped tail of an orbit, moving the model less than a quarter of a pixel, does not. So\norbiting pays one draw and a few small passes a frame (about 1.5 ms more at 3024 × 1964 on\nan M1 Max) and a still view converges in about half a second. A snapshot draws its still's\n32 samples at once (video frames one). Software WebGL has no frame: one draw, as before.\n\nEach interactive viewport keeps its renderer (a view of the page's one GPU device:\n[the GPU device](cad-renderer.md#the-gpu-device)), canvas and camera controls\nacross every mode and settings edit. It uses shadow-compatible conventional\ndepth throughout, fitting near/far planes to the current model, closeup records\nand floor/grid planes on every frame; a perspective near plane stays at or beyond\n1/256 of the orbit pivot's depth, so a closeup never trades depth resolution for\na near plane at the eye. The grid's bounds also fit the far plane;\nit is independent of the studio floor used by Render. The grid\n(`lib/viewer/stageGrid.js`, one shader quad the viewer and the snapshot both draw) is graph\npaper on the floor, after Tinkercad's workplane: thin minor lines at a round step (1, 2 or\n5 x 10^n) near twenty across the default framing at density 1, a heavier major line every 5 or\n10 of them, in one cool cyan-blue ink (`GRID_LINE_DEFAULTS`: `#4ea8cc` at 0.45 on a light\nbackdrop, `#5d9ac0` at 0.35 on a dark one; minors are the same ink at 0.6 of its strength), with\na red X and a green Y axis through the origin, anti-aliased in the fragment shader. Each family\nof lines fades only once its cells crowd under about 3.5 pixels, so there is no moire zoomed\nout or at a grazing angle; the sheet is at full strength to 1.5 framings from the origin, past\nthe default view's corners, and dissolves gently by 6, and fades a little as the plane turns\nedge-on, so it reaches the edges of the view yet has no edge of its own. Display has no separate Axes setting. The grid, the stage and the Render studio's floor are\nSIZED from the model's rest placement for every renderer, STEP included: a pose, a\nplaying routine, or a pose set in Preview never rescales or\nslides the ground; its height, the lighting and the shadow reach follow the model. Enabling photographic effects updates that live scene;\nit never clears or covers the canvas with a loading screen. Only actual context\nrecovery replaces the renderer. Quality never changes a mesh: every mode draws\ncadgen's one display mesh. Picking/topology stays demand-driven in every mode.\n\nEnabling Clip prepares its shader/cap variants before presentation. Moving an\nenabled cut stays live. Explicit neutral boundaries\nstill avoid unnecessary caps. See [responsive View updates](view-updates.md)\nfor scheduling, resource caching, loading presentation and capture readiness.\n\nRender's floor stands at the document's Z=0 plane (Model origin), at 60% opacity, its opacity\nletting geometry below that plane show; Lowest point is a choice away and is remembered in Render, as Glossy is. A snapshot's Render floor stands\nwhere the viewer's does: both resolve it from the same mode default (`RENDER_FLOOR_PLACEMENT`).\nRender's floor carries a studio's soft grounding shadow, as deep\nas the floor is opaque: darkest where the model touches it, the key light's cast\nshadow softening and fading away from the model. The viewer re-renders its\nshadows, and re-bakes this one, only for a change that can alter them: never\nwhile the camera alone moves, and never for a hover, a selection or any other\nhighlight, which leave every part casting as it did (a highlighted part keeps\nits shadow). While a routine plays or a pose is dragged, the key's cast shadow on the\nfloor follows every frame and the contact darkening under the model at most every\n100 ms; once the model stops, both are baked for its final pose. A floor at zero\nopacity bakes nothing, and a viewer that renders in software, whose key casts no\nshadow, draws no floor shadow.\nIts finish is Matte, or Glossy: a glossier floor that also reflects the model, crisp\nwhere the model meets it and softening and fading over the model's height, as a\npolished studio floor shows a product, in either appearance. The reflection is a\nsecond draw of the scene at half the canvas's resolution, before each frame the camera\nor the model moved in (about 4 ms of render CPU a frame on a large assembly); a hover\nor selection keeps the last one and catches up within 400 ms. A Matte floor allocates\nnone of it and draws nothing extra, and switching back to Matte releases it. In\nsoftware, a glossy floor reflects nothing.\nIts double-sided surface uses its actual elevation during\ncamera depth fitting, avoiding the origin-placement near-plane gap. Its color\nand opacity are independent of Background.\nThe floor has no edge, as a product shot's seamless sweep has none: it is at its\nopacity around the model and fades to nothing a few model radii out, its shadow and\nreflection with it, so a low camera or a wide view shows no horizon line or corner.\nBehind it, Background is a studio backdrop rather than a flat fill: its color lifted\ntoward white behind the middle of the frame, shaded toward the corners (more for a dark\ncolor) and above the horizon. Its opacity carries the gradient; at zero it draws nothing.\nLight Render defaults to a pure white background and retains the slightly gray\nfloor (`#e7e7e5`); dark Render's background and floor are one charcoal (`#202226`), never\nblack. Explicit background colors still override the mode default.\nExternal STEP pose and animation passes publish current placed bounds before\ndrawing, and so does the exploded view wherever it comes to rest (a slider move,\nthe end of its ease) and at most every 100 ms while it eases in or out. Depth and\nlighting follow moving parts without changing camera framing or the floor's\nfootprint.\n\nAll modes share surface-under-cursor zoom, zero-pose framing and the fit\npadding: 1.1 across, and 1.1 down on a square or narrower viewport, easing to\n1.25 at 16:9 and wider (`interactiveFitPadding`), so a wide view leaves room\nabove and below the model. Mode changes do not refit, and neither does a\nrebuild of the open file. Motion changes geometry, not what 100% means. A\nchanged camera clears current screen-space drawings; camera state republished\nunchanged after a runtime replacement does not.\n\n## Where the controls live\n\nDisplay is a dropdown from the Display tile at the strip's end (the current mode's sphere,\nbetween Projection and Preview: `kit/shell/DisplayPopover.jsx`), present for every 3D file:\nthe offered modes (`DisplayModeOptions.js`), then the Grid switch. On a phone it is the\nMore menu's Display submenu, the same list. Preview, a view of its own over the editor's,\ndraws the model in Render whatever the file's mode (the store's `renderScene()`, as\n`kit/viewport/previewViewScene.js` draws it), and the tools view never does. Preview's own\nsettings are its settings panel (`kit/shell/PreviewSettings.jsx`: Orbit, then\n`PreviewRenderSettings.jsx`), its edits the file's Preview settings. A file's model tree and Position are\nsections of the file sidebar (see\n[settings-ui.md](./settings-ui.md#the-file-sidebar-and-the-tool-stack)); nothing in them is\na display setting.\n\nDisplay and Preview, the view's controls at the strip's end, are offered by a 3D view\nalone, as its renderer declares to the shell (`useRendererShell`'s `previewable`):\n\n| Renderer | Files | Display and Preview |\n| --- | --- | --- |\n| `step` | STEP, STP | Yes |\n| `glb` | GLB | Yes |\n| `mesh` | STL, 3MF | Yes |\n| `robot` | URDF, SRDF, SDF | Yes |\n| `dxf` | DXF | No: a 2D drawing, on a surface of its own, without the shell |\n\nA view without them has no Display or Preview control, not a disabled one, and a\nrequest for Preview leaves its normal view as it is.\n","packages/ui/docs/render-types.md":"# Render types: capabilities and the backend contract\n\nBinding for viewer work that touches more than one file format. The rule this document\nexists to enforce:\n\n> Viewer code asks what a format **can do**, never what it **is**.\n\nEvery `renderFormat === RENDER_FORMAT.X` check is a place a new format must be\nhand-added, and a place an improvement to one format fails to reach the others. That is\nnot theoretical. The Orbit button was gated off per format independently and had to be\nfixed twice; when it was finally enabled for DXF, the button still did nothing because\n**four** separate format checks stood between it and preview mode (the toolbar gate, the\nworkspace handler bail, the pane's `previewMode={dxfMode ? false : ...}`, and an effect\nthat force-exited DXF from preview). Another format grew an entire parallel export path to\nan endpoint the server does not implement.\n\n## Scope\n\nThis is the rule inside the STEP renderer (`src/renderers/step`). A DXF,\na GLB, a triangle mesh (STL, 3MF) and a robot description (URDF, SRDF, SDF) have renderers\nof their own (`src/renderers/dxf`, `src/renderers/glb`, `src/renderers/mesh`,\n`src/renderers/robot`; see [CAD renderer](cad-renderer.md#kit)): a vertical slice owns its\ntools, tabs and scene outright and consults no capability table. The `dxf`, `glb`, `stl`,\n`3mf`, `urdf`, `srdf` and `sdf` rows below remain for what is not a renderer: the file\nlist's icon and label. The headless snapshot renderer consults no table either: it draws\neach of those families with the scene builder its renderer uses\n([one scene builder per family](cad-renderer.md#one-scene-builder-per-family-the-viewer-and-the-snapshot-cli)).\n\nThe DXF slice does not even have a scene: it is a canvas painted from\n`GET /__cad/drawing`, so none of the viewport capabilities below describes it — and its\nrow says so, with `assetKind: drawing` and no viewport tools (its own are its renderer's). `cadgen dxf snapshot` paints the same\npayload with the same code (`@text-to-cad/core/lib/drawing2d`), so the CLI cannot produce a\npicture the pane could not.\n\n## The capability registry\n\n`packages/core/src/lib/renderCapabilities.js` — one frozen table, keyed by render\nformat. Pure data: no behaviour, no imports beyond the format enum.\n\n| Capability | Meaning |\n|---|---|\n| `assetKind` | Which asset a format LOADS: `mesh`, `drawing`, `robot`. |\n| `iconKind` | The file-list glyph. |\n| `tools` | `select`, `pan`, `draw`, `orbit`, `screenshot`. Orbit and screenshot are true for every format WITH a viewport — they act on the viewport, not the geometry. `dxf` claims none of them: it has no viewport, and its tools (Select, Annotate, Measure) are its renderer's own, over a 2D canvas. |\n| `parts` | Per-part selection, hiding, isolate, assembly tree. |\n| `topology` | Face/edge/vertex references. Implies `parts`. |\n| `params` | `sidecar` (the model's `@step(pose=...)` block), or `null`. |\n| `animations` | Has animation clips, so transport controls apply. |\n| `artifactManaged` | Builds a package before it can render. A format listed here that the backend cannot produce a package for blocks forever, so a format the viewer renders from its own file belongs out. |\n\nWhat a 3D file's tools and view offer — Measure, Clip, Explode, the display modes — is not\nin this table: each renderer offers its own tools ([CAD renderer](cad-renderer.md#tools-on-a-scene-of-triangles)),\nand `viewFeaturesForKind` (`@text-to-cad/core/common/viewSettings.js`) is the one rule of\nwhat a file's view offers, for the viewer and a snapshot alike.\n\n### Rules\n\n- Add a capability when the **second** format needs it, never speculatively.\n- An unknown format resolves to the conservative default row (everything optional off).\n  Deliberately *not* `normalizeRenderFormat`, which resolves unknowns to STEP and would\n  hand an unrecognised entry STEP's full capability set.\n- Capabilities decide **which** panels and tools mount. Format-specific *content* — STEP's\n  tree — stays format-specific.\n\n## The content signal\n\n`selectedViewportContent` in the STEP renderer is the single answer to \"is there anything on\nscreen?\", its loaded mesh data. Toolbar gates, the CTA, preview mode, the viewport\ncontext menu and alert blocking all read it, rather than each one re-deriving the answer\nper format.\n\n## The render-backend contract\n\nThe kit's viewport (`renderers/kit/shell/ShellViewport.jsx`) is the shell and owns the camera,\n`OrbitControls`, the scene stage, frame presentation, the Draw overlay, screenshots and the\nimperative viewer API. A renderer's **scene** (the contract `renderers/kit/scene.js` re-exports from core; STEP's is\n`renderers/step/scene/stepScene.js`) owns geometry only:\n\n1. **Consume content**: the document its renderer loaded (a STEP's mesh data, a GLB's native\n   scene, a mesh file's objects, a robot's parts). A GLB's, a mesh's and a robot's scene is\n   built in `@text-to-cad/core`, where the snapshot CLI builds the same one.\n2. **Publish bounds** so the shared fit and its zoom baseline work. The mesh\n   path does this via `applyRuntimeModelBounds` after composing; a backend with no mesh\n   calls back with its own bounds instead.\n3. **Optionally install loop-tuning hooks** on the runtime. All are inert unless set, so\n   the mesh path is unaffected:\n   - `renderOnDemandOnly` — do not hold the render loop open for a whole gesture.\n   - `idleQualityDelayMs` — raise the idle-restore delay.\n   - `onIdleQualityRestore` — restore quality before the pixel ratio, so the expensive\n     frame and the drawing-buffer reallocation do not land on the same vsync.\n   - `resolveExtraPixelRatioCap` — cap resolution below the shared caps.\n\nA backend never reaches into the camera, controls or stage. If it needs something from\nthem, that is a shell feature and belongs in the shell where every format gets it.\n\n### Adding a format\n\nDeclare a registry row, implement a backend, add a fixture to the sweep. Do not touch the\nshell. If you find yourself adding a format check to `FloatingToolBar` or to anything under\n`renderers/step/scene/`, the capability you need is missing from the table.\n\n## Enforcement\n\nA ratcheting policy test in the repo where this app is developed counts identity\nchecks in non-test client code: the number may only go down. It also asserts a\ngrowing set of files at **zero** — the toolbar, every module of the STEP scene\n(`renderers/step/scene/`), the alert\nbuilder, the file-list icon and status, and the home screen — since those are the surfaces\nevery format flows through. Lower the budgets in the same commit that removes checks.\n\nWhat is left is deliberate. `useCadAssets` is allowlisted: choosing and running a loader\nper format is its whole job, and the `assetKind` field names *which* loader without\npretending the implementations are the same. `stepArtifactStatus.js` keeps its checks\nbecause STEP package error codes, the `stale` flag and the renderable-GLB fallback are\nSTEP vocabulary. STEP is now the only artifact-managed format: a DXF is rendered from\nthe file that is on disk — the backend flattens those bytes to 2D primitives per request\nand never owns a package for one (`owns_dxf_path` always answers False).\n\n## Standing gate\n\nThe `web` CI job's `scripts/test/test-viewer-browser.sh` opens one fixture per load path\n(STEP, STL, DXF, URDF) through the real backend and the bundled viewer and asserts each\ndraws something with no page errors: a blank-but-error-free viewport is the signature\nfailure here (a shader that fails to compile, a gate that hides the geometry). It runs on\nevery change to shared viewer code; there is no separate manual sweep.\n\n## Known non-uniformities\n\nRecorded so they are not mistaken for bugs, and so the next person knows the cost:\n\n\n## Scene recipe conformance\n\nInspect and Render resolve through `@text-to-cad/core/common/sceneSettings.js`.\nInspect's fixed light/dark workbench basis follows app appearance; Render uses\nits independent studio configuration. Legacy CAD theme preferences are not an\ninput. The internal `themeSettings` recipe remains a renderer implementation\ndetail, normalized through the shared core schema so every backend receives the\nsame lighting, materials and stage settings.\n\nThe browser qualification in `scripts/test/test-viewer-browser.sh` opens every\nload path through the real backend and bundle and checks mode framing, that a saved\nrevision keeps the camera and that Zoom to fit frames the revision; picking and the\ndisplay presets are the `packages/ui` browser specs'. Both use\nisolated generated fixtures rather than a sweep of saved theme presets.\n","packages/ui/docs/renderers.md":"# File renderers\n\nThe viewer renderers, `cad` (STEP), `dxf`, `glb`, `mesh` (STL, 3MF) and `robot` (URDF, SRDF, SDF), and\nthe kit and shell they are built on are in [CAD renderer](cad-renderer.md). They are\nthis package's renderers because every host registers them.\n\nA renderer only one host registers belongs to that host, with its own\ndependencies and tests. It uses only this package's public exports, and they are\nwhat a host renderer may rely on:\n\n- `defineFileRenderer` and the registration contract from `@text-to-cad/ui/file-viewer`\n  ([FileViewer](file-viewer.md)), including `fallback: true` for the one\n  renderer that takes files nothing else matches.\n- The file it is handed (`FileMetadata`) and its `FileSource`, which names and\n  describes files and reads nothing else: a renderer reads a file's content through\n  what its host injects into it (the CAD renderers take a `CadWorkspaceService`).\n  Every resource a renderer acquires carries a release; it returns that as the\n  prepared document's `dispose`, and FileViewer releases it when the request is\n  cancelled, the file changes, or the view unmounts.\n- The host ports in `@text-to-cad/ui/host`: `useViewerHost`, and `PromptContextAction`\n  for prompt delivery.\n- Shared chrome from `@text-to-cad/ui/navigation` (`EmptyState`),\n  `@text-to-cad/ui/primitives/*` and `@text-to-cad/ui/utils`, styled by this\n  package's `styles.css` and the host's own Tailwind build.\n","packages/ui/docs/settings-ui.md":"# Viewer interaction and settings design system\n\nThis is the binding contract for the viewer's tools, its file sidebar and its\nsettings, in both apps. Build from the shared pieces — the\n[toolbar button](../src/primitives/toolbar-button.jsx),\n[FloatingToolBar](../src/renderers/kit/tools/FloatingToolBar.js),\n[SubToolbar](../src/renderers/kit/tools/SubToolbar.jsx),\n[ToolPopover](../src/renderers/kit/tools/ToolPopover.jsx),\n[FileSidebar](../src/renderers/kit/tools/FileSidebar.jsx),\n[ToolStack](../src/renderers/kit/tools/ToolStack.jsx),\n[ToolPanel](../src/renderers/kit/tools/ToolPanel.jsx),\n[the floating surface](../src/lib/floatingSurface.js) and the\n[FileSheet primitives](../src/renderers/kit/inspector/FileSheet.js) — and extend\nthem rather than recreating their layout in a renderer. Environmental effects\nbelong to apps through the [host contract](viewer-host.md).\n\n## Ownership and layout\n\nFileViewer owns the navbar — the logo's app menu ([Settings](#settings)) included, built from\nthe host's `links` and the `appSettings` `CadViewer` hands it — and the file explorer (a\npopover under the file's name). RendererShell owns the scene's chrome: the tool strip and\nthe view's controls at its end (Display, Preview), the sub-toolbar of the tool in hand under\nit, the file sidebar, Quick Edit, the view cube and Preview. Renderers supply\ntheir tools, their tools' rows, their sidebar sections and their document state; the shell never\ninspects a format's parts, joints or topology. No pick or tool opens or closes the explorer.\n\n| The host (web, desktop) supplies | The shared UI decides |\n| --- | --- |\n| Where the tab record lives (`TabRecordStorage`: the web's sessionStorage, the CAD app's memory) | The record: its settings (the file sidebar's and tool stack's layout, the appearance, the home's layout) and the view of the file on screen (its camera, Display settings, its playback settings, and renderer slices), what is written when, and what is never stored (`@text-to-cad/ui/tab-store`, `kit/shell/fileView.js`) |\n| `navigation.home`, `links` (its version, GitHub, Discord, where a new issue opens, how a link opens), `appSettings` (the person's own settings), `colorScheme` (the person's theme and how it changes) | The navbar's order and look, over a file and over the home, the app menu (one menu in the viewer and on the home) and what a new issue says |\n| `host.files`, `fileActions`, `navigation`, `clipboard`, `promptContext`, `promptFiles` | When a copy, capture or open happens and what it carries |\n| `host.environment`: color scheme, keyboard `platform`, `reducedMotion` | How the chrome honours them |\n| `onError`, for errors the viewer hands up | Preview, tooltips, keyboard scope, the tool strip and sidebar, the camera |\n\nHosts pass no Preview, chrome-visibility or notification props; there are\nnone.\n\n- **Navbar.** One row, the same in every app, over every file and over the home. Left: the text-to-cad \"C\"\n  logo, named Menu, the size of the row's icon buttons, with no chevron, which opens the\n  **app menu** (see [Settings](#settings)) — a host with no home has it too — then, where the\n  host keeps the view's own history (`history`: the CAD app's views, which no browser holds),\n  **Back** and **Forward**, two arrows walking it as a browser's do, from file to file and to\n  and from the home (a step like a file), each there always and disabled where it has\n  nowhere to go; then the open file's name and its ⋯. Each of these that\n  is a control (`NAV_ITEM_CLASS`) is transparent at rest, takes the same accent background\n  while the pointer is on it and while its menu is open, has a pointer cursor and no tooltip:\n  it says what it is. The name\n  is that control where it opens the explorer (below), where the host's files can be\n  browsed; where they cannot it is plain text. The ⋯\n  is the file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host can do it, and no ⋯ where it can do none. The name has\n  no right-click menu and there are no crumbs. The left's controls sit edge to edge, spaced\n  by their own padding alone: the logo's 24px button, Back, Forward and the ⋯ in 20px ones,\n  and the name 4px in from its edges, so about 7px separate one glyph from the next. Right, only what a host or a file adds: first, the\n  update — a blue download button, there only when the host\n  found a newer release, whose menu says the step to it, how this host updates and what\n  is new. Then, while a person has put an alert card away: the card's own icon (a circle\n  with an exclamation mark, red for an error and amber for a warning, named and hinted by\n  the alert's title), which brings the card back and goes with it — the renderer's one\n  navbar action. Then, where the host shows the view small (inline in a conversation,\n  where the view is the whole viewer), its **Full size**, corner brackets. A CAD file publishes no navbar action but that\n  icon. The person's own settings, the theme among them, are never here: they are the app\n  menu's.\n\n  *Design note: the strip is for tools; the view's controls are the navbar's.* What a host or\n  the file adds (the update, an alert's icon, Full size) is the navbar's right end, and last\n  after them the view's own controls, **Display** and **Preview** (`navbarSlot`): they act on\n  the whole view, whatever tool is in hand, so they are no tools. In Preview the same place\n  holds its own controls, **Preview settings** and **Exit preview**. A host with no navbar has\n  them over the view instead: Display and Preview ending the strip after a thin rule, the\n  Preview's pair in the view's top-right corner. A new per-file button that is no tool goes\n  with them.\n\n  A host's home has the same navbar, with only the logo and its app menu (without Back to\n  files) and, where the host keeps the view's history, **Back** and **Forward** at the left\n  and, at the right end, the host's update, when there is one, and **Full size**, where the\n  host shows the view small — the same buttons, sizes and places as over a file. Under it,\n  the page: the TEXTTOCAD wordmark and byline, then GitHub (its mark, a link to the project:\n  it is open source), Discord and X, as icon links (GitHub and Discord are the app menu's\n  too, X its footer's \"Made by\"; no light/dark button: Theme is the menu's), then Recent Files with its search, grid/list switch\n  and Open where the host has a file chooser: the models opened before, pinned first, as cards\n  or rows.\n  The update shows only for a release\n  later than the version the page names, whatever the host says. A file that will not open\n  shows a page under the navbar: \"File does not exist\" and the file's path, or \"Could not\n  open that file\" and the reason, each with **Go home** where the host has a home. A view\n  drawn as a picture (`compact`, the home's thumbnails) has no navbar either, and draws the\n  model alone: no tools, view actions, cube or Quick Edit.\n- **File explorer** is a popover under the file's name (`FolderExplorer.jsx`), aligned to\n  the name's left edge, 18rem wide and never resized, as tall as its rows up to 28rem or\n  the room below it: past that its list scrolls. It floats over the view and the file\n  sidebar, so opening it resizes and moves nothing. It starts in the open file's folder and\n  lists one folder at a time — its subfolders, then its CAD files — in compact rows: one 24px\n  line of 12px text each, its kind a small light glyph in one column 5px in from the row's\n  left end and 4px before the name (a folder its disclosure chevron, with no folder icon; a\n  file its type's icon), as the file sidebar's trees draw theirs. A press on a subfolder opens it\n  inline, under itself, its rows 10px further in and hung from a faint line under its\n  chevron, read once when it first opens; another press closes it, and Right and Left do the\n  same from the keyboard. A double-click opens it in the explorer's place, as a crumb does —\n  the breadcrumb then names it. The breadcrumb is\n  as many of the last three folders as fit whole: every crumb is its folder's whole name, the\n  farthest giving way first to the \"…\" (an ellipsis icon), which lists every folder above the crumbs to jump to,\n  and only the folder it is in, left alone beside the \"…\", is cut (its name a hint while it\n  is). The folder it is in reads in the foreground color, the others muted; there is no up\n  arrow, and the open file is highlighted, the list opening scrolled to centre its row, once (a\n  long folder's too). Opened, its filter has the keyboard, so typing\n  finds a file at once; on a phone the explorer itself holds focus, keeping the keyboard\n  down. \"Filter files...\" under the breadcrumb searches\n  every file nested under the folder (the server's bounded search, debounced 150ms, each\n  keystroke cancelling the search before it); a search that stopped early says \"Stopped\n  early. Keep typing to narrow the search.\" Up and Down move between the filter and the\n  rows on screen, the rows of a folder open inline among them. A pick opens the file and\n  closes it, as do Escape and a press anywhere outside it.\n- **Tool strip** at the top centre, 8px down, never wider than the view less 8px at each\n  side (it wraps before it would be): the tools, left to right, in **two sections** parted by one\n  thin rule (a tool's `group`: the tools that pick, Select, Pencil, Shapes and Measure; then the\n  model's effects, Explode and Clip), and, where there is no navbar, the view's controls,\n  **Display** and **Preview**, directly after the last tool, in the same second section with no rule\n  between (a file with only tools that pick has its view's controls after a rule, as their own second\n  section). Where a navbar holds the view's controls the strip is the tools alone: below. 24px tiles with 14px icons,\n  an even 4px apart, a tool's chevron touching its button, in a 34px row (`FloatingToolBar`,\n  `data-cad-toolbar`). A file with no tools has no strip. On a phone\n  ([mobile](#the-file-sidebar-and-the-tool-stack)) the tiles are 36px with 18px icons, a chevron\n  20px wide, in one row that never wraps. Centre it on the viewport whenever it fits;\n  clamp its position to keep 8px from the edges and from the file sidebar's tile at its left.\n  Its available width still uses all the room after that tile. What does not fit goes, from the\n  strip's end (the view's controls where the strip has them, then the tools from the last), into **More tools** (an ellipsis, last), a tool a row ticked while it is\n  in hand and Display opening its popover from More (`touchStripFit`).\n- **Sub-toolbar** under the strip, 8px below it, centred: the tool in hand's one row of\n  controls (`SubToolbar`, `data-cad-sub-toolbar`), there for as long as the tool is up (see\n  [Tools and lifecycle](#tools-and-lifecycle)). Select and Measure have none. On a phone its\n  buttons are the strip's 36px tiles and its sliders as tall, the slider rows 20rem wide.\n- **Tool stack** under the sub-toolbar, in the same centred column: a renderer's own\n  `toolPanels`, where it hands any (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)). The column\n  stops 8px above the cube's line (`VIEWPORT_STACK_BOTTOM`), so it never runs past the\n  view.\n- **File sidebar** at the top-left, 8px in: a toolbar of tiles, one per section of what there\n  is to read of the file — a STEP assembly's Model tree, a robot's Links, Position, an SDF's\n  metadata, the routines' Animation — whatever tool is in hand; a tile opens its section in a\n  panel under the tiles, one at a time, down to 8px off the view's bottom at most. On a phone it\n  is one tile, opening one panel with the sections as tabs (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)).\n- **Quick Edit** at top-right, 8px in, a STEP's or a robot's: the box of the note a\n  person writes to their agent, there only while something is picked, drawn or measured\n  (see [Quick Edit](#quick-edit)). The rest of the right side of the view is clear.\n- **View cube** at bottom-right, 2px from the right and 8px off the bottom so the axes\n  drawn in its lower corner never touch the edge (`VIEWPORT_CUBE_BOTTOM`), in a 6rem area:\n  enlarged face/edge/corner hit areas and neutral hover and XYZ guides. A host whose own\n  control floats over that corner lifts it clear with `--cad-viewport-corner-bottom`, the\n  room that control takes from the bottom edge. A host can instead anchor it at bottom-left\n  with `--cad-viewport-corner-left: 2px` and `--cad-viewport-corner-right: auto`.\n  Codex's tab presentation uses this for all its views to avoid the floating composer.\n  Preview omits it, and so does a phone.\n- **Display and Preview** end the navbar, for a 3D file (`data-view-controls`, the box\n  FileViewer hands the renderer as `navbarSlot`): **Display** (the active mode's sphere: see\n  [Display settings](#display-settings-and-section-primitives)) and **Preview** (a play\n  triangle, the way into Preview), the navbar's icon buttons, hinted below them. They are no\n  tools: pressing one leaves the tool in hand as it is. Display's dropdown opens down from its\n  button, aligned to its end, never taller than the viewer below it. A view that is not 3D has\n  none of them, and neither does a view drawn as a picture or one that is loading or failed to\n  load; in Preview the same place holds its own controls (see\n  [Preview](#camera-animation-and-preview)). With no navbar (a host showing the view\n  alone) they end the tool strip after its rule, and Preview's stand in the view's top-right\n  corner.\n- **Nothing sits at the bottom centre**, in the tools view or Preview: a file's routines\n  play from the file sidebar's Animation section (see [Preview](#camera-animation-and-preview)).\n- **Model update status** sits under the strip and its sub-toolbar, centred, and where\n  there is no strip (Preview, a compact view, a load) at the top centre, in the strip's\n  34px row, in every host. It is renderer chrome: the navbar carries no status. On mobile\n  it is a tappable progress icon whose popover names what is loading.\n\n## Tools and lifecycle\n\n| File | Tool strip, left to right |\n| --- | --- |\n| STEP | Select, Pencil, Shapes, Measure; Explode (two or more parts), Clip, Display and Preview |\n| URDF / SRDF / SDF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| GLB | Select, Pencil, Shapes, Measure; Explode (several rigid nodes that draw), Clip, Display and Preview |\n| 3MF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| STL | Select (in hand, the view's navigation: an STL picks nothing), Pencil, Shapes; Clip, Display and Preview |\n| DXF | Select (in hand, and the view's hand: a drag pans, a tap picks an entity), Pencil, Shapes, Measure; no view controls: a drawing is 2D |\n\nThere is no activity dot. A thin rule parts the tools from the view's controls, which\nare no tools (see [Ownership and layout](#ownership-and-layout)). Position is no tool:\nits knobs are Select's, while Select's filter picks Position, and its controls are a\nsection of the [file sidebar](#the-file-sidebar-and-the-tool-stack), and so is Animation, where\nthe file's routines play (see [Preview](#camera-animation-and-preview)). A file with no tools\nand no 3D view has no strip at all.\n\n**A tool the file cannot offer is not on the strip**: never shown disabled.\nRenderers leave it out of the `tools` they hand over; `FloatingToolBar` draws\nwhat it is given. Until a file has loaded, what it offers is not known: a tool\nthat depends on it (Explode) is shown idle meanwhile, a robot's filter offers\nPosition meanwhile, and the whole strip is disabled while loading. Unavailable and\nidle are different states.\n\nOne tool owns pointer input, and it alone is lit on the strip. An effect a person set —\nan explosion, a cut, a pose — stays on the model once its tool\nis put down, with nothing on screen for it but the model. **A press on the tool in hand\ndoes nothing**: a tool is put down by taking up another (Select, most often), or by Escape, which\ntakes Select up. A press on\nExplode or Clip with its effect set takes it up again, as it is; unset, it starts neutral.\n\n| Tool | Pressing it | Its sub-toolbar | Leaving it |\n| --- | --- | --- | --- |\n| Select | The default tool of every 3D file but an STL; picks what its filter ticks, or a GLB's nodes, a 3MF's objects (Shift adds; Delete deletes annotations and measurements); what is picked is Quick Edit's chips | None: its filter is the chevron after its button | The selection stays, lit and in Quick Edit, under any tool |\n| Pencil / Shapes | Draws on the view: Pencil with the pen, the highlighter or the eraser, Shapes with the rectangle, line, arrow, ellipse or text; each button is the tool last chosen in it | One row for both: the tools of the one that is up, then Color, Stroke width, Placement and Clear annotations (Undo and Redo are ⌘Z, ⇧⌘Z) | The annotations stay in the model, their chip in Quick Edit; each tool keeps what it draws with, the strip showing its icon |\n| Measure | Arms picking two entities and reading what the pair makes (a STEP's exact vertices, edges and faces, what its filter ticks; on a mesh, two points on its surface, approximately, in its file's unit; a drawing's ends, centres and lines, in the unit it declares, else \"units\"); each result a chip in its colour | Its measurements, as chips, then Clear measurements; none while nothing is measured | Unfinished picks are cancelled; completed measurements stay, drawn on the model, their row back with the tool |\n| Explode / Clip | The effect as it is set, or neutral; an edit applies it (Explode's, as a motion: see below) | The slider, its amount beside it, Reset (Clip's: its axis first, whose dropdown holds its settings) | The effect stays on the model |\n\n**One motion at a time.** Every tool's state stands beside every other's — the selection,\nmeasurements, the annotations, Clip — except the two that move the parts: **Explode** and\nthe **Position** pose. They replace each other: taking up Explode puts the pose back at its\ndefault, and using Position (a knob dragged, a value set or a named pose chosen) takes\nExplode off, with nothing set aside to come back. While the model is exploded there are no\nknobs. Put down, Explode keeps its explosion under any other tool until Position is used. A\nroutine played from the Animation section is the third motion: playing it puts Explode and the\npose back at rest, and using either puts the routine back at rest; paused, its frame stays on the\nmodel as a pose does. Preview's routine is its own and moves nothing of the tools view's.\nAnother file has the motions its tools have, under the same rule, decided by value\n(`kit/tools/model-effects/motionExclusion.js`): a GLB's Explode, routine and Clip; a robot's\nPosition and Clip.\n\nA tree is the file sidebar's, on screen under every tool; a pick in it takes Select up\nbefore it acts.\n\n**Removing an effect.** Explode's and Clip's sub-toolbars end in\nReset (\"Reset explode\", \"Reset clip\"): always there, disabled while there\nis nothing to take away, it removes the effect — the amount back at zero, nothing else (Clip's\naxis and its settings stay as set) — and leaves the tool up. Position's way back is its Pose row's Default. A\nmeasurement's chip X deletes it, and Measure's Clear measurements all of them; the Annotation chip's X clears the annotations.\n\n**Select and Measure are the tools with a menu on the strip** (Measure's on a STEP only), each its selection filter: the\nchevron right after the button (`ToolSettingsMenu`'s \"chevron\" trigger, `kit/tools/ToolModeMenu.jsx`),\nas tall as the tile and touching it. The button and its chevron are **one control**\n(`FloatingToolBar.js`): one rounded fill lights the pair on hover, while the tool is in hand and\nwhile its menu is open, and the chevron's half only brightens over it, on its own hover and\nwhile its menu is open, with no line between the halves. A change\nto a filter takes its tool up. A press on any other tool is its only action. Whatever a tool can\nbe set to while it is up is its sub-toolbar. A dropdown is ordinary — under its own button,\nfree to overlap the view — and closes with no exit animation, so a quick second tap (touch\nincluded) always reaches its trigger. Choosing a value closes it; ticking an option leaves\nit open, as do Clip's axes and the app menu's Theme. Menu checks sit on the right. The other dropdowns over the viewport are Display,\nthe sub-toolbars' (Clip's axis and settings, the drawing tools' colour, stroke width and placement), its\ncontext menu, and the Animation section's routine and Playback settings (`PlaybackMenu.jsx`, on\n`ToolPopover`). Preview's settings are a panel, not a dropdown (see [Preview](#camera-animation-and-preview)).\n\n**Select's menu** (`kit/tools/select/SelectFilterMenu.jsx`) is a CAD tool's selection\npriority (Fusion's Select > Face Priority, Blender's vertex, edge and face modes), not a\nchecklist: the chevron's dropdown, headed \"Pick\", lists ONE choice of what a click on the model\ntakes, each with its glyph — **All** first, the default, then each kind alone. Choosing\none puts the menu away and takes Select up. A STEP's (`SelectionModes.jsx`'s `stepSelectModes`):\n**All** (the pointer: a face or an edge where a part's are loaded, the part elsewhere,\nPosition's knobs, the annotations and the measurements), **Parts** (a whole cube; an assembly's\nonly — in a part file a press that reaches no face or edge picks the whole part), **Faces** (a\nfaint cube, its top face filled), **Edges** (a faint cube, one edge heavy) and **Position**\n(where the file has joints to move: its knobs alone, the model picking nothing). A robot's:\n**All**, **Links** and **Position** (where a joint can be driven; a robot with nothing to\ndrive has no menu). The annotations and measurements are no choice of their own: they are drawn\nover the model, so a click on one is never taken for the face under it, and a kind alone (Faces)\npicks that kind only. All is chosen when a file opens; the choice holds while the file is\nopen, across updates of it, and another file starts on All. The chevron's name says what\nis chosen (\"Selection filter: everything\", \"Selection filter: Faces\"), and Select's button wears\nit as a badge on the pointer (`modeGlyphs.jsx`'s `ToolModeIcon`; none for All), so what a\nclick takes is on the strip, not only in the menu. After a rule, a STEP's menu ends in how a face\nor edge pick grows, each with its glyph and independent of each other: **Group edges** (the\nconnected edges with a picked edge), shown while edges are picked, and **Group faces** (the\nsmoothly joined faces with a picked face), shown while faces are; a hidden one keeps its choice\nfor when it shows again.\n\nThe geometry chosen is picked as one mode (`step/workbench/selectionFilter.js`'s\n`selectGeometryMode`): All, a press takes the face or edge it lands on where the part's\ntopology can be picked, and the part elsewhere; Parts, the part; Faces or Edges, that topology\nalone. The mode sets the model tree's shape: under All, or Position (no geometry at all),\nit is the person's own (put back as it\nwas when they left it, with the owners of what is still selected kept open); Parts alone\nopens every assembly and shuts every part; Faces or Edges open every assembly. A\npart is a leaf in every shape: nothing opens under it, and a face or edge picked in the\nviewport is revealed at its part's row. Outside the person's own shape the disclosure is\nlocked (chevrons shown, not pressable) and Expand/Collapse leave the row menus. Under Faces\nor Edges a part's topology is asked for as its row comes on screen in the tree, never for a\nwhole large assembly at once; a viewport press on a part not yet loaded loads that part and\npicks, and the model tree's heading says \"Loading…\" meanwhile. In a large assembly (more than 300\nrows of assemblies and parts, `LARGE_TREE_ROWS`) a part's row coming on screen asks for\nnothing: the pointer resting on the part in the viewport (150ms) or pressing it asks.\n\n**Sub-toolbars** (`kit/tools/SubToolbar.jsx`): the tool in hand's controls, one row hung\nunder the strip, centred, 8px below it, on the strip's surface, its buttons the strip's\n24px tiles, its groups — what the tool does, then how it looks and what clears it — parted by a\nthin rule. It stays up for as long as its tool does, and has no heading: the tool's lit\nbutton above names it. Explode's and Clip's are one shape — what leads\nthe slider, the slider, its value right beside it and only as wide as\nits text, the tool's actions (neither has any), then Reset, last, at the same spot in both,\nwith no rules — and one width (`SLIDER_SUB_TOOLBAR_CLASS`, 202px; a phone's 320px). The slider\ntakes the rest of the row. A tool's options never sit in the row as checkboxes, labels or\nbuttons of their own, which would squeeze the slider: they are the dropdown of what leads it\n(Clip's axis).\n\n**Pencil and Shapes.** Drawing is two plain strip tools, a button each, with no chevron and no menu:\n**Pencil**, drawing with the **Pen** (the freehand ink, tapering with pressure or\nspeed), the **Highlighter** or the **Eraser**; and **Shapes**, with the **Rectangle** (what it opens on), **Line**, **Arrow**,\n**Ellipse** or **Text** (a mark put down in place). The tool's button shows the icon of what it draws with\nnow, and a press takes up the one last chosen. Pencil and Shapes share the annotation and its history, whichever is up, but not the style: the colour, stroke width\nand placement are kept **per drawing kind** — the Pen, the Highlighter (which opens in neon yellow) and Shapes (all five\nshapes share one) — and the row shows and sets the tool in hand's own — and **one sub-toolbar**,\n\"Annotation controls\" (`annotation/toolbar.jsx`'s `AnnotationToolbar`), a single row, never a\nsecond, from the left: the **tools of the one that is up**, a button each, the one in hand pressed (with Pencil up, Pen,\nHighlighter, Eraser; with Shapes up, Rectangle, Line, Arrow, Ellipse, Text: choosing one is how the strip tool changes what it\ndraws with); a thin rule; then what is drawn next, each a dropdown whose button shows its value, no\nchevron: **Color** (no heading, a grid of swatches, unnamed but for assistive technology: the neons and\none neutral that reads against the viewer, white in dark mode and near-black in light, then\nthe colour wheel, which opens the system's colour picker for any other colour, kept once the\npicker closes), **Stroke width** (no heading, four weights, 1, 2, 4 and 6px, the pen drawn to look the\nsame weight; on the button and as each row, a straight line drawn at that weight, unnamed but for assistive\ntechnology, the current one checked) and **Placement** (laid out as the strip's menus are: headed with its name, a\nrow per choice, the check on the right: **On surface**, the default, its glyph a sphere: each point of the next mark on the visible\nsurface under it; **On plane**, its glyph a sheet: the whole mark on the plane of the surface it\nstarts on; a flat view, a DXF, has none); then **Clear annotations** (the trash), disabled while there is\nnothing to clear. Under the **Eraser**, which draws nothing, Color, Stroke width and Placement are disabled (greyed out, still\non the row so it does not jump). Undo and Redo have no buttons: they are ⌘Z and ⇧⌘Z (Ctrl+Z, Ctrl+Y). The row is the same row for both tools at every width; on a phone its\nbuttons are 36px tiles and, where the row is wider than the view, it wraps rather than scrolls (there is no second menu on\nthe strip for it). The **Highlighter** draws a freehand stroke like the pen's, but broad and translucent: five times\nthe chosen stroke width, 35% opaque, round at its ends, with no pressure taper; it is an ordinary stroke, picked,\nerased and recoloured as any (its Stroke width in the selection's bar is the same setting). The tools have no Select or Pan of their own (Select picks\nannotations, a right drag or Space orbits). Never a restyle of ink already down: the\nannotations Select picks have their own bar, a small floating toolbar on the tool strip's\nsurface just above them — Color and Stroke width dropdowns, Fill for a closed\nshape, a rule, Delete. An update of the model forgets the annotations too, their history with\nthem (Undo has nothing to bring back), and leaves the tool in hand, with its tool, colour,\nweight and placement. The pen and the shapes share one default stroke\nwidth. The annotations have no panel: while there are any they are Quick Edit's\n**Annotation** chip (its X clears them), and while Pencil or Shapes is up the copy shortcut writes\nthe view with its annotations to the clipboard as a PNG. A first stroke opens\n[Quick Edit](#quick-edit), whose header says **annotation** while there is ink. The drawing tools\ndisable the cube without hiding it.\n\n**Measure.** Its button is the ruler, badged as Select's is with what it\npicks alone. On a STEP, its menu is Select's own (`kit/tools/measure/MeasureFilterMenu.jsx`):\n**All** (when a file opens), **Vertices**, **Edges** or **Faces**, one chosen. A press takes an\nENTITY, whole, as a CAD measure tool does: an edge's end within 10px of the pointer is its\nvertex, else the edge, else the face (`measureFilter.js`). What lights under the resting pointer\nis what a press takes, with what it is worth on its own beside it (a vertex's X Y Z, an edge's\nlength or ⌀, a face's area). With one kind chosen, only that kind is taken: an edge's end is still\nits vertex under Vertices, and anywhere else a press takes nothing. Shift held, a press takes the\npoint on the model as it is. In an assembly, a press on a part whose faces and edges are not loaded asks\nfor them and takes the pick there once they arrive.\n\nThe reading follows from the pair (`@text-to-cad/core/lib/viewer/measureEntities.js`), not from\nwhere on each the pointer happened to be:\n\n- two vertices: the distance, with ΔX ΔY ΔZ;\n- parallel faces, parallel edges, a vertex to a face or an edge: the perpendicular distance, \"⟂\";\n- a hole (a circular edge or a cylinder) to anything: from its centre or axis, so two holes\n  read \"C-C\" and a hole to a face its distance from that face;\n- faces or edges at an angle: the angle, \"∠\", the acute one;\n- anything else: the shortest distance between the two.\n\nA pair with no gap (an edge in its face, two flats at one height) reads as the distance between\nthe two points picked. A press on the entity already held lets go of it, as does a press on\nnothing. On a mesh there are no vertices, edges or faces to take: a press takes the point on its\nsurface under the pointer, and the menu goes. A drawing's Measure has no filter.\n\nIts sub-toolbar is its measurements (`kit/tools/measure/measurementChips.jsx`'s `MeasurementsBar`),\nthere while Measure is the tool and something is measured, and no row at all while nothing is:\neach completed measurement a chip, Quick Edit's own (`ReferenceChips.jsx`, a few then \"+N more\"),\nthe ruler in the measurement's colour (the colour of its line on the model) and its reading; its\nX deletes it, resting on it lights its line and labels it, and its card, opening under it, is the\nfull reading (what the pair makes, the angle or the shortest distance with it, the delta along\neach axis between two points, what each end bound to); then a rule and **Clear measurements**.\nMeasurements are the person's alone: none of them goes to the agent, in Quick Edit or its\nsnapshots, and measuring never opens Quick Edit. They stay, drawn on the model, under every other\ntool; a press on Measure while it is up does nothing.\n\n**Explode and Clip** have no enabled checkbox, and their sub-toolbars are drawn alike.\nExplode's: its slider, the amount (a percentage), **Reset explode**. Clip's: its axis — a\nborderless button reading the axis in hand, \"X ⌄\", lit while its menu is open — its slider, the\namount, **Reset clip**. The axis's dropdown holds all of Clip's settings, on Display's surface\nand in its type, as narrow as its content, with no heading and no glyphs: first X, Y and Z, a\ncompact group of the app menu's Theme buttons (24px squares, its radius, no track) in a row as\ntall as an item (a phone's 40px, as Theme's row), the axis in hand in the accent a highlighted\nitem wears; a rule; then two of Display's `DropdownMenuCheckboxItem`s, plain text with the check\nat the right: **Part colors** (ticked by default: each section cap in its\npart's normal colour, without hover or selection tints; unticked, every cap one amber) and\n**Reverse** (unticked by default: which side of the plane is kept). Caps stay opaque, a\ntransparent part's too. A press on any of them leaves the menu open, and none of them applies a\ncut; on a cut, a new axis or Reverse turns it keeping its share. They are settings of the\nfile's view (`clip.axis`, `clip.partColors`, `clip.invert`, stored and restored with its\ndisplay), kept across tools, by Reset clip and by taking up an unset Clip. Explode opens at 0%, Clip at no\ncut; an edit applies the effect, and the slider back at zero removes it. An axis alone is not\nan effect. The view settings own both effects, so a mode reset preserves them and a restored\neffect reappears on the model. Clip's slider, left to right, cuts deeper through the original\nbounding box; pose, animation and Explode never redefine the range. It shows the share cut\naway, while the view stores where the plane stands (`clip.at`, in millimetres), so a model\nedit leaves the plane where it was and the slider reads 0% or 100% if the plane ends up\noutside the new box. Explode is offered only where there are parts to separate: a STEP\nassembly and a GLB of several rigid nodes. A STEP part file has no Explode tile, as it has no\nModel tree (`STEP_PART_VIEW_FEATURES`).\n\n## The file sidebar and the tool stack\n\n**The file sidebar** (`kit/tools/FileSidebar.jsx`) holds what there is to read of the file,\nwhatever tool is in hand — the strip is for using tools, the sidebar for reading the file: a STEP\nassembly's **Model tree** (a part file, whose tree would be its one row, has none), then\n**Position**, where the file has joints to move; a robot's **Links**, an `.sdf`'s **SDF**, then\n**Position**, where a joint can be driven; a drawing's **Layers**, where it has four or more\nlayers or any block reference (a flat pattern's CUT and BEND has none): each layer's pen, name,\nentity count and eye, its block references under it; and, after them, the shell's own **Animation** for a\nfile with routines (`kit/tools/playbar/AnimationSection.jsx`). A file with none of them has no\nsidebar. It is a toolbar of its own at the view's top-left, 8px in, on the strip's line and of\nits surface, padding and 24px tiles (`data-file-sidebar-tools`, \"File sidebar\"): one tile per\nsection, a tree's a tree icon named for it (Model tree, Links), Position's a spline, Animation's a\nclapperboard, any other section's a page named for it. Preview offers its Position and\nAnimation alone, their tiles bare in its top-left corner; the rest stay mounted, out of sight.\n\n- **One section at a time.** None is open as a file opens. A tile opens its section in a panel\n  under the toolbar, 8px below it, and is pressed while it is open; another tile opens its\n  section in the panel instead; a second press of its tile closes it, and so does the X that\n  ends the section's head. The panel slides in from the left as a section opens with none open\n  before, and back out as the last closes (200ms, none under reduced motion); from one section to\n  another it stays where it is. Closed, the panel is out of reach (`inert`). Every section stays\n  mounted, open or not, so a tree keeps its expansion, search and scroll. Nothing folds: a\n  section is its head and its body.\n- **Sections.** Each section is a `ToolPanel` drawn as one: a 24px head — its name 12px in from\n  the panel's edge, what its body puts there (`ToolPanelHeadAction`: Position's and Animation's\n  Reset), then its X — and its body. A tree's head is its filter instead of its name, the X\n  after it. The section is its content's height and the panel as tall as it is, up to its cap;\n  past that the whole panel scrolls, as one, the head pinned to its top\n  (`data-pinned`). A tree's rows are mounted as that scroll brings them into view\n  (`kit/inspector/VirtualRows.jsx`).\n- **Filter.** A tree is headed by its filter (\"Filter model\", \"Filter links\": \"Filter…\"), in place of a title,\n  always there, a tree row's 24px and 11px text, its icon over the rows' glyph column\n  (`primitives/tree-filter.jsx`'s `TreeFilterInput`, `dense`), its X clearing it once something\n  is typed, Escape too. The model tree's filter is followed by \"Loading…\" while a part's faces load\n  and **Show all** while parts are hidden. Every tree's search is one ranked flat search\n  (`kit/inspector/modelTreeSearch.js`).\n- **Size, each section's own.** A section's panel opens a tool panel's width\n  (`TOOL_PANEL_WIDTH`, 164px) and as tall as its section, capped at the room under the toolbar.\n  It is sized from its bottom-right corner alone, by Quick Edit's grip (`kit/tools/ResizeGrip.jsx`):\n  width and cap together, by pointer, or by keyboard (a separator named \"Resize file sidebar\":\n  Left/Right nudge the width and Up/Down the cap by 16px, Home and End take both to their\n  bounds). Its width runs from 120px (`FILE_SIDEBAR_MIN_WIDTH`: room for a heading's name and its\n  buttons; a tree's names truncate) to half the viewer, its cap from 64px to its room. One write,\n  when the gesture lets go (or per key), never per pointer move; each section keeps its own size\n  (`sidebar:<id>`), so the model tree may be wide and tall while Position stays narrow.\n- **On a phone** the sidebar is one touch tile, **Sidebar** (a panel icon,\n  `data-file-sidebar-toggle`), at the left of the strip's line, the strip padded clear of it on\n  both sides and keeping to its tools (`FileSidebar`'s `phone`). It opens one panel on the section\n  last open (the first, at first): its top a row of tabs, one per section with its tile's icon\n  (shadcn's `ToggleGroup`, `data-file-sidebar-tab`), and the panel's one X at that row's right end\n  (a section's head then has none; a panel of one section has no tabs and keeps its head's X).\n  The panel runs down to the view's bottom inset, or is as tall as its section if that is less,\n  stopping above Quick Edit's peek (`--cad-quick-edit-peek`), at a phone's own width (288px,\n  within the view's inset), with no grip. Its text is the panel's one size (13px, as the\n  Display menu's), its tree rows 36px with 16px glyphs, its filter, dropdowns and value fields\n  32px (`globals.css`). In Preview its tiles are its top-left's, as on a desktop.\n- **The layout is the person's.** The section sizes are one of the tab's settings, across its\n  files (`CadPreferences.toolStack`: `{ panels: { [id]: { width?, height? } }, collapsed: { [id]: boolean } }`,\n  a section's panel under `sidebar:<id>`, kept beside the appearance; any other key a stored\n  record carries is dropped). `panels` holds only what a person set; `collapsed` is the tool\n  stack's folded panels. Which section is open is not kept: a file opens with none. A new tab (a\n  cleared record) puts everything back at its defaults.\n\n**The tool stack** (`ToolStack`) is the column under the strip and the sub-toolbar, centred\nwith them, for a renderer's own `toolPanels`. STEP and the robot renderer hand none: their\ntools' controls are sub-toolbars and what there is to read of their files is the sidebar's.\nIts panels follow these rules:\n\n- **Two kinds of panel.** A `resizable` panel is the person's to size, on its own; every\n  other panel is *fixed*: one width, its content's height, and no grip.\n- **One width.** Every panel opens at `TOOL_PANEL_WIDTH`: 164px, a strip of six tools (six\n  24px buttons, 2px gaps, 4px padding and a 1px border). A fixed panel is exactly that wide; a\n  resizable one is only ever made wider, up to half the viewer. The panels hang left-aligned\n  in the column, each at its own width. Content truncates to fit; it never widens a panel.\n- **Heights.** A panel is exactly its content's height — never padded to a minimum. A\n  resizable panel has a *cap* the content grows up to and then scrolls inside: it opens capped\n  at the stack's own height (`toolPanelDefaultHeight`). A cap is never a floor.\n- **One grip, on a resizable panel only**, the sidebar's: its bottom-right corner, Quick\n  Edit's grip, moving its width and cap at once, a separator in the tab order named after its\n  panel, keyed as the sidebar's is. A folded panel has no grip: there is no height to set.\n- **Never past the viewer.** The column stops 8px above the cube's line\n  (`VIEWPORT_STACK_BOTTOM`, `calc(6rem + 16px)`). When the panels need more, a `\"tree\"` panel\n  gives way first, down to 128px or its content, then the `\"details\"` panels together, each in\n  proportion to its height, down to 96px or its content; a `\"fixed\"` panel keeps its height.\n  If what cannot give way still does not fit, the column itself scrolls — a panel is never cut.\n- **Headings.** A panel's heading row reads as Display's section headings do (11px, regular,\n  `TOOL_PANEL_HEADING_TEXT_CLASS`), 28px tall, 8px in: a title, a summary where there is one,\n  then at its right end its own actions, the chevron where it folds (up while open, down while\n  folded) and an X where it has something to remove. Every small button in a heading is 20px,\n  2px apart and 4px from the edge. A panel can end in one full-row button under its body,\n  which never scrolls (`footer`, `ToolPanelFooterButton`).\n\n- **Surfaces.** Three, defined once (`lib/floatingSurface.js`), with one border: the\n  tool strip, its sub-toolbars and the stack's panels, which stay up beside the model, share\n  `FLOATING_CHROME_SURFACE_CLASS` — the background at 30% and barely blurred\n  (1px), so the model behind them is easy to make out; every menu and popover\n  over the viewport, the app menu and Display included, share `FLOATING_SURFACE_CLASS` (the\n  background at 75%, blurred), so their text never competes with the model; and the file\n  sidebar, mostly text but up for as long as the file is open, is between them\n  (`FILE_SIDEBAR_SURFACE_CLASS`: 55%, a 4px blur).\n- **Scrolling.** Every scroll region in the viewer's chrome — the file sidebar, a panel's\n  body, the stack's column, the explorer, a menu, the alert card — is the\n  `ScrollArea` primitive (`primitives/scroll-area.jsx`, shadcn's): thin overlay\n  bars in the theme's colours, shown while the pointer is over the region. No\n  native `overflow-auto` scroller in chrome; `src/designSystem.test.js` holds it.\n  The home (the model library) is a page, not chrome: it scrolls as a page does,\n  with the platform's scrollbar, and only when there is more of it than fits.\n- **Nothing opens elsewhere.** A pick shows as Quick Edit's chips; the sidebar is on screen\n  under every tool. No pick or tool opens or closes the explorer, on desktop or mobile.\n\nTree rows inset their backgrounds 4px from the panel's edges, and the search box\nshares that inset. A tree in the file sidebar (Model tree, Links) is dense (`TreeRowSurface`'s\n`dense`): 24px rows in the panels' 11px text, and ONE light 12px glyph per row in one\ncolumn, as the explorer has: a subassembly's or folded group's disclosure chevron, turned down\nwhile open, or a part's box (Links draws its chevrons alone), 4px before the name. The glyph\ncolumn starts 5px in from the row's left end (`TREE_ROW_INSET_PX`), centred under the find\nfield's search icon and as far in as the icons of a row's actions sit from its right end, in the\nSTEP tree and the explorer alike; each level adds\nthe explorer's compact indent, 10px (`TREE_INDENT_PX`), its rows hung from a faint line under\ntheir owner's glyph (`TreeRowGuides`), as the explorer's are; its search box is its section's head. An assembly row's\nactions, shown on hover and kept while they are on, are **Isolate** then the **Hide/Reveal** eye; a part file has no\nIsolate. A Links row that draws something — a link with visuals, or one visual —\nhas the eye alone, in its search hit too: a link's hides its own visuals, never its\nchild links, and on a partly hidden link hides the rest. They float over the row's right end rather than taking width from it:\nthe name runs the row's full width and, while an action shows, fades out half a\nrem before them (a mask, `ROW_NAME_UNDER_ACTIONS`), so nothing is drawn behind the\nbuttons and the row keeps its own colour.\nModel and link finds and the explorer's filter share `TreeFilterInput`; the explorer's rows are compact\n24px lines, as the trees' are, but in 12px text.\n\n**Mobile** is below 600px of FileViewer width (Material's compact window class) — the one viewer breakpoint\n(`useViewerMobile`); chrome never uses window breakpoints, nor the pointer's kind: a small window\nis worked as a phone is. What a finger reaches is the gesture's own (`pointerType`): a touch takes an\nedge from 20px off (an edge within 16px wins over the face under it), ink, a measurement and the\neraser's ring from 16px (`TOUCH_HIT_PX`), and a joint's knob from 22px; a mouse keeps its few px.\nOn a phone every slider is a 36px band with a 20px thumb, and the selected ink's bar has 36px\nbuttons. A pinch zooms at the touch speed from the first gesture after a load. A phone is read at arm's length\nand worked with a finger, so its chrome is one step up the scale, after the platforms' own\nguidance (Apple's 44pt and Material's 48dp touch targets, an 11pt type floor and 15-17pt body\ntext, and iOS Safari zooming into any field under 16px): type 12, 13, 14 and 15px for `text-micro`,\n`text-tiny`, `text-xs` and `text-ui`; text entry 16px; menu, select and tree rows 36-40px; the\nnavbar a 48px row of 36px controls; the strip and the sub-toolbars 36px tiles (the strip one row,\n**More tools** holding what does not fit; the drawing tools' row the same single row, wrapping where it must).\nThe scale is set once, in `globals.css`, for the whole page while a phone's view is on it, so a\nportalled menu, select or hint is the same size as the chrome it opened from. There is no view\ncube; the file sidebar is one tile at the strip's left, its sections tabs of one panel; and\nQuick Edit is a bottom sheet (see\n[Quick Edit](#quick-edit)). The explorer is the same popover, never wider than the window less\n1rem. The shortcut hint is hidden.\nTouch works for every tool: one finger orbits, two pan and\nzoom, a tap picks; a pinch, a cancelled pointer or a camera drag is never a\npick.\n\n## Display settings and section primitives\n\nDisplay is not a tool. Its tile ends the strip after the strip's rule, between Projection and\nPreview, and shows the current mode's sphere icon. It opens a stock shadcn dropdown menu\n(`DisplayPopover.jsx`): a muted **Display** label, the modes as a `DropdownMenuRadioGroup`,\na separator, then **Grid**, a `DropdownMenuCheckboxItem`. On a phone the strip's **More**\nmenu has a Display row whose nested submenu is the same list (`DisplayMenuItems`), with no\nlabel. Checks sit at the row's right end. Choosing a mode closes the menu; ticking Grid\ntoggles it and leaves the menu open (`onSelect` prevents the close). Clicking outside or\npressing Escape closes it. Opening it leaves the active tool and its controls in place. A\n2D view has no Display or Preview control.\n\nDisplay is a choice of mode and the Grid switch, and nothing else: a mode has no settings\nof its own, and there is no settings page. The modes, in order, are **Solid** (shaded with\nthe model's CAD edges, the default), **Shaded** (no edges), **Parts** (Solid with each part\nits own colour), **X-ray**, **Hidden line** and **Wireframe**, filtered by the view's\ninjected capabilities (`viewFeaturesForKind`, `DisplayModeOptions.js`): a STEP assembly\noffers all six, a STEP part file all but Parts, and a file without CAD edges (STL, 3MF, GLB,\na robot) Solid, with Parts when it has several parts, objects or links. **Grid** draws the\nmeasuring grid in every mode, off by default. Each row has its icon, the sphere drawn as the\nmode draws a model (and a tile of floor grid for Grid). Render is no display mode: Preview\ndraws the model in Render whatever the file's mode, with its own settings (see\n[Preview](#camera-animation-and-preview)). Per-file storage keeps the mode and the grid;\nold-shaped stored display settings are dropped setting by setting, never migrated.\nProjection is a menu of its own, shared, orthographic by default; Preview's Render resolves\nto perspective while preserving the shared choice.\n\nPreview's settings panel (`PreviewSettings.jsx`, its body `PreviewRenderSettings.jsx`) is\nthe one settings form: Orbit, then Render's Part colours, Lighting, Background and Floor.\nIt shows all groups under static section headings and dividers, with no collapsible\nsections or cascading settings menus. A heading is 24px of regular `text-tiny` in the\nforeground colour, a rule above every one but the first. A feature that switches on and off\n(Orbit, Floor) is the checkbox at its heading's right end, the title naming it; there is no\nseparate row for it. While the feature is off its section is that heading alone. Every\nsetting is one 24px row: its label in a 4rem muted column, its control filling the rest, so\na form's controls start on one line. A label is short under its heading (\"Colour\",\n\"Opacity\" under Floor); the control's accessible name stays whole (\"Floor opacity\"). A\ndropdown or colour is as wide as its value, at the row's right end. Sliders are shadcn's,\nas everywhere, with a committed value field after them. By part's palette is a row of\nswatches, then add and remove. Choices use Select controls. Continuous values use compact\nslider and committed-number fields; their keyboard input stays with the field. Colours\nretain swatches and a custom colour picker. Floor is on by default, at the model's Z=0\nplane. A long form scrolls within the available viewer height. A nested Select or colour\npicker owns Escape and outside interaction before its parent surface. The last ite\n\n<!-- [TRUNCATED AT 50KB] -->","packages/ui/docs/view-updates.md":"# Responsive View updates\n\nView controls always edit the per-file `viewSettingsStore` immediately. That\nstore is the only settings authority. A mode is a preset batch; the renderer\nnever branches on its name. Rendering completion cannot rewrite controls,\npersistence, feature gates, or the Custom label.\n\n`kit/view-settings/viewUpdateCoordinator.js` owns one active operation and one replaceable\nrequested scene recipe. `kit/view-settings/useAppliedViewSettings.js` binds it to React.\nRapid edits replace pending work; they do not accumulate in a queue. Preparation\nis cancellable. Once scene reconciliation starts it finishes serially, but a\nsuperseded result is not presented. File changes/unmount cancel work and reject\noutstanding captures. Errors leave controls editable and expose Retry.\n\n## Cost policy\n\n`kit/view-settings/viewUpdatePlan.js` is the single classification point. Compare resource\nboundaries, never preset names:\n\n| Preparation | Live update |\n| --- | --- |\n| Lighting enable, quality, softbox size/fill | Exposure, rotation |\n| Surface style/color source, crossing opaque/transparent | Surface color, opacity within one transparency class |\n| Edge enable/visibility | Edge color |\n| Floor enable | Floor color, opacity, placement |\n| Clip enable/disable | Clip position, Reverse and Part colors after preparation |\n| Appearance shader change | Projection, background, grid, axes, Explode |\n\nBoth paths yield until the controls have painted, then apply the latest recipe.\nOnly the preparation path loads resources and compiles shader variants. Live\nupdates coalesce to the next frame and do not wait for an artificial debounce.\n\nThe Clip and Explode tool buttons add neutral control panels. Editing a slider or\nnumber enables the effect; X or pressing the tool again resets and removes it.\nClip uses original model bounds for its slider and the rendered plane, independent\nof pose and Explode. Its neutral endpoint is the maximum coordinate (minimum when\nflipped). It has one active axis and plane, exposed in its panel as a compact X/Y/Z\ndropdown and a single cut-amount slider. The slider starts neutral on the left and\nadvances from maximum to minimum coordinate; a flipped cut (`invert`) reverses that direction. CLI coordinates, including zero, remain supported. Clip and Explode stay outside mode presets.\n\n## Renderer and resources\n\n`kit/look/studioEnvironmentCache.js` keeps at most three reflection maps per\nviewport. `studioEnvironment.worker.js` generates PMREM in worker WebGL and\ntransfers half-float pixels; model geometry stays on the main renderer. Rotation\nand exposure reuse the map. Superseded work terminates the worker; completed\nmaps are reused by key. Workers terminate after transferring their pixels, so inactive tabs do not\nretain extra WebGL contexts. Disposal releases textures and any pending worker. Browsers\nwithout OffscreenCanvas/Worker use a synchronous compatibility path after the\ncontrols paint; the full responsiveness benefit requires worker WebGL.\n\n`kit/view-settings/viewUpdateGate.js` preserves the previous canvas framebuffer while the\nscene is reconciled and Three's `compileAsync` prepares its programs. The\nrenderer/context and cached geometry stay mounted. `viewportBuffer.js` queues\ncanvas resolution changes and flushes them inside the next actual draw callback.\nChanging DPR or dimensions outside that callback clears the retained frame, so\nno quality effect or resize observer may resize the backing buffer directly. A viewport\nresize DRAWS that frame instead, synchronously inside its ResizeObserver callback (after\nlayout, before paint): the canvas is sized 100% by CSS, and a frame requested from there\nwith `requestAnimationFrame` would land a frame late, painting the old picture stretched\nover the new box whenever the viewer changes width in one step (a window snap). That synchronous frame replaces any queued one, so a drag still draws once\nper frame, and an idle viewer draws nothing. While the gate holds, the draw is skipped\nlike any other, the buffer keeps its size, and the held picture waits for the frame that\nreleases it (`kit/viewport/ViewportResize.browser.test.mjs`). Presentation resumes after\npreparation; completion is acknowledged after an actual draw. Camera input is\naccepted during preparation, though the displayed pose can briefly wait while\nthe scene is held. Shader support and GPU drivers can still impose synchronous\nwork (notably shadow allocation/first draw); this is not a second renderer or a\npromise that all GPU work is preemptible.\n\nScreenshot/prompt capture awaits the coordinator's latest presented revision,\nso it cannot accidentally capture a half-applied recipe. Ordinary snapshots\noutside FileViewer retain their own scene setup lifecycle.\n\n## Status placement\n\n`kit/status/ViewUpdateStatus.jsx` only takes `status`, `onRetry`, and\n`className`. It has no scene/store knowledge. RendererShell draws it centred under the tool\nstrip and the tool in hand's sub-toolbar, or at the top of the viewport in the strip's row\nwhere there is no strip (preview, a load), without changing scheduling. It appears after 150ms with “Updating view…” (or\n“Preparing section view…” for Clip), stays nonmodal, and never disables controls.\nFast changes produce no spinner. Failures show a retry action.\n\nTests cover latest-request wins, cancellation back to the visible recipe,\nserialized reconciliation, failure/retry, capture readiness, disposal, and\nStrictMode/control independence. Real browser checks must also exercise cold\nRender, rapid preset/clip edits, a completed capture, and a warm return.\n","packages/ui/docs/viewer-host.md":"# Viewer host contract\n\n`FileViewer` requires one explicit `ViewerHost` from `@text-to-cad/ui/host`.\nShared UI implements rendering and document interaction; apps supply environmental\neffects. No shared feature discovers Electron, browser clipboard, a backend URL,\npersistent storage or page navigation. DOM, canvas, workers and layout remain\nshared. Missing optional methods mean an operation is unsupported.\n\nThe host contains `files`, optional native `fileActions`, `clipboard`,\n`promptContext`, the optional `promptFiles`, `navigation` (the optional `openFile(path)` and\n`home()`), the optional `links` (the app menu's), the optional `usage`\n(`used(feature)`: what the person used, for the host's telemetry to count, never what\nthey made; absent, nothing is counted) and `environment`. `environment` carries the resolved `colorScheme`,\nthe resolved `locale` (the language the view reads in: the person's choice, else what the\ncontext infers, as `@text-to-cad/ui/i18n`'s `useLanguage` resolves it; none, or one the viewer\ndoes not speak, reads English),\nthe keyboard `platform` (`darwin` shows ⌘, anything else Ctrl), the app's own\n`reducedMotion`, honoured beside the system's `prefers-reduced-motion`, and\n`compact`: a view drawn as a picture (the home draws its thumbnails this way, out\nof sight), where a CAD renderer draws the model alone — no tools, view actions,\nview cube or Quick Edit — and FileViewer draws no navbar. A view shown small in a\nconversation is not compact: it is the whole viewer, and its host hands it a way\nto full size as `fullSize` (`CadViewerProps.fullSize`), among the last of the navbar's\ncontrols and the home's, before the host's colour scheme control. CAD is a separate registration supplied with a\n`CadWorkspaceService`; the generic FileViewer does not import CAD. The HTTP CAD\nadapter can serve both apps, while desktop owns native runtime startup/recovery.\nSee [workspace resources](../../core/docs/workspace-resources.md) for resource\ntickets and cache identity.\n\nA host passes no tolerance to the STEP renderer: it draws each part at the display mesh its\npart's record names, which cadgen derived at its display tolerance\n([the display mesh](loading.md#1-the-display-mesh)).\n\nApps create services for the workspace lifetime. File tabs borrow them, while\nmounted renderers own scenes, document controllers and temporary resource leases.\nUnmounting one view releases its work without disposing another view's services\nor admitted cache writes. Hosts dispose a service when its workspace closes.\n\n## Interface map and implementations\n\nThe linked TypeScript definitions are the authoritative signatures. Import\ntheir compiled public package exports in application code; these source links\nare for reading and maintaining the contracts.\n\n| Contract | Definition | Public entry point |\n| --- | --- | --- |\n| `ViewerHost`, `ClipboardPort`, `PromptFiles` (a Quick Edit's views and annotations, saved as JSON), `usage` | [Host types](../src/host/types.ts) | `@text-to-cad/ui/host` |\n| `FileSource` (`stat`, and `list` and `search` where the host has them), `FileActions`, `FileViewerState` | [File viewer types](../src/file-viewer/types.ts) | `@text-to-cad/ui/file-viewer` |\n| `PromptContextPort` (`deliver`, `send`), bundles, references, delivery receipts and `formatPromptMessage` (the one message a Quick Edit is) | [Prompt types](../../core/src/prompt/types.ts) | `@text-to-cad/core/prompt` |\n| `CadWorkspaceService` (the catalog of the files on screen, `folder`, `search`), `createCadClient` (`{ origin, fetch, … }`), `isMissingFileError`, `CadResourceProvider`, worker tickets | [CAD service types](../../core/src/client/types.ts) | `@text-to-cad/core/client` |\n| `createHttpPromptFiles` (the viewer server's `PromptFiles`: `POST /__cad/prompt-files`, writes JSON and returns paths) | [Prompt files](../../core/src/client/promptFiles.js) | `@text-to-cad/core/client` |\n| `StepRendererOptions`, `CadLiveBinding` | [STEP registration](../src/renderers/step/index.ts) | `@text-to-cad/ui/renderers/step` |\n| `TabStore`, `TabRecordStorage`, `createTabStore`, `useTabViewerState` (the tab's one store: its settings, the view of the file on screen, and `FileViewer`'s state from both) | [Tab store](../src/tab-store/tabStore.ts), [the record](../src/tab-store/tabRecord.ts) | `@text-to-cad/ui/tab-store` |\n| `CadPreferenceSource`, `createCadPreferences` (the tab's settings as renderers read them) | [Viewer preferences](../src/renderers/workspace/preferences.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `DxfRendererOptions` (2D drawings; declines every camera, display and selection command) | [DXF registration](../src/renderers/dxf/index.ts) | `@text-to-cad/ui/renderers/dxf` |\n| `GlbRendererOptions`, `LiveViewBinding`, `LiveViewController` | [GLB registration](../src/renderers/glb/index.ts), [live binding](../src/renderers/kit/shell/liveBinding.ts) | `@text-to-cad/ui/renderers/glb` |\n| `MeshRendererOptions` (STL, 3MF), `LiveViewBinding`, `LiveViewController` | [Mesh registration](../src/renderers/mesh/index.ts) | `@text-to-cad/ui/renderers/mesh` |\n| `RobotRendererOptions` (URDF, SRDF, SDF), `RobotLiveController`, `RobotLiveState` (`selectedLinks`, `selectedPartIds`) | [Robot registration](../src/renderers/robot/index.ts) | `@text-to-cad/ui/renderers/robot` |\n| `ViewerCommands`, `ViewerCommandSource` (the host requests every viewer renderer takes) | [Viewer commands](../src/renderers/workspace/commands.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `createLiveRegistry`, `LiveRegistry` (the host's handle on the mounted view: its renderers' `live`) | [Live registry](../src/host/liveRegistry.ts) | `@text-to-cad/ui/host` |\n| `ModelLibrary`, `ModelLibrarySource`, `useModelThumbnail` (a host's home, under the navbar: the CAD title and byline over the host's links (GitHub, Discord, X), then the models opened before, pinned first, as cards or rows, and with none, where the host has a chooser, an \"Open File\" card that opens it; placeholder cards or rows while the list is read (again every two seconds while the home is up, so a model rebuilt meanwhile is pictured again), and a spinner over the model being opened, which takes no second press until `open` settles; `pick` only where the host has a file chooser) | [Model library](../src/library/ModelLibrary.tsx), [thumbnails](../src/library/thumbnails.ts) | `@text-to-cad/ui/library` |\n| `CadViewer` (FileViewer over a CAD client, one file by absolute path or the home: the five CAD renderers, catalog following, the home, the \"File does not exist\" and \"Could not open that file\" pages, the library's pictures) | [CAD viewer](../src/cad-viewer/CadViewer.tsx) | `@text-to-cad/ui/cad-viewer` |\n| `createCadFileSource` (a CAD client as a read-only `FileSource`: `stat` through the client's `resolveEntry`, `list` through `folder`, `search` through `search`), `createCadFileActions` (the file menu's Copy path, the absolute path, and Reveal), `normalizePath`, `baseName`, `joinPath`, `contentRevision` | [Catalog](../src/cad-viewer/catalog.ts) | `@text-to-cad/ui/catalog` |\n| `ViewerLinks`, `viewerLinks` (the version, GitHub, Discord, where a new issue opens, the newest release and how to update, and how a link is followed), `issueUrl` (a new issue filled in — its title, labels and body — its address kept under `ISSUE_URL_MAX`) | [Host types](../src/host/types.ts), [links](../src/file-viewer/navigation/links.js) | `@text-to-cad/ui/links` |\n| `buildId` (for an app's Vite config: which build this is, \"\" for the release's own, whose environment names its version in `TEXT_TO_CAD_RELEASE`, else the checkout's commit) | [Build id](../scripts/build-id.mjs) | `@text-to-cad/ui/build-id` |\n\nStart with the actual compositions: [web App](../../../apps/web/src/App.tsx) and the\nMCP app's [ModelView](../../../apps/mcp/src/ModelView.tsx), both one `CadViewer`.\nIts imports lead to the app-owned `host/`, `adapters/` and persistence\nimplementations. [Web storage](../../../apps/web/docs/storage.md) documents\nbrowser lifetimes. Shared component tests can\nuse the [explicit fake host](../src/host/testing/host.ts).\n\n## A CAD renderer's controls\n\nThe controls of a CAD renderer (STEP, GLB, mesh, robot) are its own, over the viewport: its\ntool strip, the tool in hand's sub-toolbar and its file sidebar, its view's controls (Display,\nPreview) drawn into the navbar's `navbarSlot` (`settings-ui.md#the-file-sidebar-and-the-tool-stack`); a\nDXF's are its own canvas's. Nothing a renderer does opens or closes the explorer (only the\nfile's name opens that). The layout a person gives the file sidebar — each section's size —\nand the tool stack's panels is one\nof the tab's settings (`settings.toolStack` of the tab record, `@text-to-cad/ui/tab-store`),\nbeside the appearance and the home's layout.\n\n## Preview and renderer navigation actions\n\nPreview is the shared shell's own state (`previewing`); there is no host\nprop for it and a host cannot start or observe it. It is a 3D view's alone, and each\nrenderer says whether its view is one: it declares `previewable` to the shell\n(`useRendererShell`), as STEP, GLB, STL/3MF and URDF/SRDF/SDF do. A view that does\nnot — a DXF's 2D drawing, which draws its own surface without the shell — has no\nPreview at all, no control and not a disabled one, and nothing that asks for it\n(a link or a host request, should either come to) takes it there: it keeps\nthe normal view. The CAD app's Full size, inline, is the host's display mode, not\nPreview: it shows the normal view, of any file. Preview keeps the navbar, so the app\nmenu and the explorer still lead to other files; the host is not told it is on.\nPreview and the tools view are two states: entering leaves the tools view's state as it\nis (the pose, the routine, the selection, hidden and isolated parts, Explode and Clip,\nmeasurements, the camera, the tool and its panels) and Preview starts from the model as\nauthored, with its own camera, still, and its routine at rest, drawn in Render whatever the\nfile's display mode (the tools view never draws Render); leaving throws Preview's state\naway and gives the tools view back exactly as it was, its routine included, in the file's\nmode. Preview is a view of its own, mounted over the tools view's while it is open\n(`kit/shell/PreviewView.jsx`), with its own scene, camera, pose and routine, sharing only the\npage's GPU device; the tools view's viewport stays mounted under it and nothing of it changes.\nNothing of Preview is persisted. Routines play from the file sidebar's Animation section in either\nview. Preview opens still: Orbit is off every time it opens, and its settings panel turns it\non. The file's view's `playback` — Orbit's speed, Autoplay, and the chosen speed and loop —\nis kept between visits to Preview and across a reload, and a file's routine plays on entry\nonly when its Autoplay is on. The rules are in\n[settings-ui.md](settings-ui.md#camera-animation-and-preview).\n\nA renderer can publish `FileNavigationAction[]` through\n`RendererViewProps.onNavigationActionsChange`. The CAD renderers publish one, and only\nwhile a person has put away an alert card the model survives: the card's own icon in its\ncolour, named after the alert, which brings the card back (`useAlertDismissal`,\n`kit/status/ViewerAlertCard.jsx`). The\nshared navbar shows these at its right, after the host's update button and before the\nhost's Full size and colour scheme control. Each action declares its icon, accessible label, an\noptional shorter hover `hint`, disabled state and invocation callback. Registration belongs to the mounted\nfile generation: publish an empty list on cleanup; departing renderers cannot\nreplace a new file's actions. Publish only when action metadata changes; stable\ncommands should read the current viewport through a ref, avoiding parent/child\nrender loops. These actions use existing host capabilities for effects. What a\nperson tells the agent about a CAD file is [Quick Edit](#prompt-handoff)'s.\n\nA renderer's controls for the view are the renderer's own, drawn into the box FileViewer\nhands it at the navbar's right end (`navbarSlot`): the CAD viewer's for a 3D view, Display and\nPreview, and in Preview its Preview settings and Exit preview; the navbar stays over it.\nWhere there is no navbar (a view shown small) the renderer draws them over its view. The host's `captureRequest`\ncommand is the same capture: to a composer destination it delivers the view and\nthe selection through `promptContext.deliver`. Neither route detects the platform.\n\n`navigation.openFile(path)` shows a file, by its absolute path, in this view: a pick\nin the explorer, a renderer's link. `navigation.home()`, where the host has a home,\nshows it in this view: from a file, the app menu's Back to files (the menu the navbar's\nlogo opens) leads to it; over the home the same navbar has the logo and its menu, with no\nBack to files. `CadViewer` implements both\nover the host's `onShow(path)` (`''` is the home), and gives a host a home only with a\n`library`: a host whose own navigation shows one file (Codex's file handler) passes none,\nand its view shows that file and nothing else — no home, its menu no Back to files, no\nexplorer, and no `openFile`, so a renderer's link to another file (a robot's mesh) is plain\ntext there. A host that keeps a view's own history,\nwhere no browser does (the CAD app's views), hands it as `history` (`ViewerHistory`:\n`canGoBack`, `canGoForward`, `back()`, `forward()`; `CadViewerProps.history`): the navbar\ndraws Back and Forward over it, over a file and over the home alike. What the history holds is\nthe host's (the CAD app's: every file the view showed and the home, a step like a file, the\nagent's shows and the person's moves alike); the web hands none, its page having the\nbrowser's, where the home is a step too. `onShown(path | null)` says which file the catalog\nhas on screen (null for the home, or a file still resolving or missing), for a host that\nnames its page after it or records it. The host decides what showing a file means,\nbecause only the host knows which view shows it: the web viewer is one page per machine\nand writes the file into its URL (`?file=`, a new history entry, so the browser's back\nand forward move between files, and a bare URL is the home), and the CAD app, which\nrenders in Codex (the sidebar, a tab beside each thread and a file handler) and inline in\nClaude Desktop and other MCP Apps hosts, shows it in the view that asked or, from the\nhome, launches it.\n\n## Adding a shared feature\n\n1. Implement reusable interaction and presentation in UI, with cross-consumer\n   non-React domain behavior in core. Renderer-private helpers stay with their\n   renderer in UI. Keep project, session and operating-system workflows in apps.\n2. Reuse an existing injected contract. If a new environmental effect is needed,\n   extend its narrow consumer-owned interface and implement it in each app, or\n   explicitly advertise that the host cannot perform it. Shared UI must not\n   fall back to browser globals or branch on `isWeb`/`isDesktop`.\n3. Use subscribed capability/destination state for availability and labels:\n   Quick Edit offers only what the destination and the prompt port can do. Use\n   a named additive slot for an extra app-enabled interface; the shared\n   renderer never imports the app's component or stores.\n4. Define identity, lifetime, cancellation and result semantics with the\n   contract. Publish serializable view state through the controlled binding;\n   the app chooses its storage. Keep workspace services stable across tab mounts.\n5. Update this contract or its linked domain guide, add focused shared/adapter\n   coverage and verify affected host integrations. Exercise source changes and\n   late results for asynchronous effects, and warm reuse for resource changes.\n   Run `npm run check:boundaries` and rebuild compiled packages before app checks.\n\nPlatform-agnostic UI can use DOM, canvas, React and renderer-owned workers.\nFilesystem access, transport selection, credentials, clipboard, persistent\nstorage, page navigation and native process lifecycle remain host responsibilities.\nSo do a transport's limits: a host whose channel caps one reply declares the\nmost one batched read may ask for (`createCadClient({ maxBatchBytes })`), and the\nshared loader never asks for more; the host carries a longer body in parts (the\nCAD app's tunnel reads it a range at a time), and the loader sees it whole.\n\n## Prompt handoff\n\nContext actions use `PromptContextPort.deliver(context)`. Desktop\ninserts into a compatible draft; web prepares clipboard representations. Ordinary\nexplicit copy/paste controls use the separate `ClipboardPort`. Delivery never\nsubmits a prompt. `send(context)`, present only where the host has a chat to post\nto, posts the context as the person's message now (`sent`); absent, nothing can\nsend one.\n\nThe portable types and validators live at `@text-to-cad/core/prompt`. One versioned\nbundle contains ordered text, reference and attachment parts with unique IDs.\nAn attachment has a MIME type, name and Blob or Promise of Blob. Its optional\n`about` array names reference-part IDs, so a screenshot and several selections\ncan travel together, its optional `label` (\"Annotation\") says what it is for a\nmessage that names it by path, and its optional `path` (a path, or the promise of\none) is where it is already saved: `resolveAttachmentPaths(context)` waits for\nthem all. Producers freeze resource and selection identity before\nasynchronous capture. Blob URLs are delivery leases, never portable identity.\n\nA reference contains a workspace-file identity (the file's absolute path,\n`/`-separated: `/a/b.step`, `C:/a/b.step`; validation rejects any other spelling)\nor HTTP(S) URL plus a tagged\nselection: `whole-resource`, `text-range`, or `cad-selector`. Text positions are\nzero-based UTF-16 with an exclusive end. CAD selectors use core's validated\ncadgen grammar, not STEP entity numbers. Preserve a revision when available;\nreferences do not promise to survive edits. Shared serialization handles quoting.\n\n`formatPromptMessage(context, { attachmentPath })` is the one\nmessage a Quick Edit is, whether sent, queued or copied: what the person wrote;\nthen `File: <path>`; then `References:` and one reference per line; then each\nJSON file by its label and path (`Views (cadgen snapshot --job): <job.json>`,\n`Annotation: <file.annotation.json>`), where `attachmentPath` says where it was saved.\nNo pictures are rendered, attached or sent.\n\n\n`PromptContextAction` (a host renderer's prompt action) reads the subscribed\ndestination and labels the action Add to prompt or Copy for prompt. It calls delivery during the\nuser gesture, before awaiting capture: browser activation and desktop destination\nbinding depend on this. Availability and advertised attachment/combination limits\nbelong to the host. Every result is acknowledged: added, copied, sent, partial,\ndeferred, cancelled or failed. `partIds` describes accepted/written parts, not a\nclaim that a different application pasted them.\n\nDesktop captures the compatible draft destination before awaiting attachments,\nvalidates the complete bundle and rechecks that destination before atomic draft\nacceptance. Switching chats cannot redirect an in-flight capture. Existing text\nand attachments survive; operation IDs prevent duplicate acceptance. Workspace\nmismatch uses the app's explicit Start chat here recovery. Invalid attachments\nleave the draft unchanged. Direct capture failure never shows success.\n\nWeb supports text/reference serialization and one PNG. A combined clipboard\nwrite reports that text and PNG are separate representations; some receivers\npaste only one. Unsupported attachment types or combinations fail explicitly.\nHosts must resolve/validate accepted attachments and consume failed encoders;\nthey do not transfer Promise or Blob values across native IPC.\n\nQuick Edit is the shared, host-neutral note to the agent\n([the design system](settings-ui.md#quick-edit)), and its buttons are what the host can carry out.\nEvery way it goes carries its words, references and a snapshot packet captured at\nCopy, Queue or Send. The packet includes the current view plus distinct drawn views\nwith remaining ink; an equivalent drawn entry uses the exact current framing.\nShared input and inline annotation ([annotation](annotation.md)) appear once; measurements\nare the person's and never go. Each job may override shared fields. Projection and lens are preserved.\nPackets up to 1024 bytes of compact UTF-8 JSON are inlined; larger packets are\nwritten as one temporary `.snapshot.json` and named by path. Hosts use\n`resolvePromptMessage` to resolve an attachment's optional `inlineText` before its\npath, keeping destination binding inside the gesture. Drawing, selecting and typing\nkeep data in memory. Nothing renders at the press. The agent saves inline JSON to\na temporary file before calling `cadgen snapshot --job <file>`.\n**Copy Prompt** is always there: the message goes through\n`ClipboardPort.writeText`, which takes a `Promise<string>` so the write starts\ninside the gesture while the files are still being made; its references name their\nfiles by absolute path, as copied references do (below), and every file is named by\nits path, since text carries no file (without `promptFiles`, a prompt carries none). **Queue** is there where the\ndestination is a composer (`destination.kind === \"composer\"`): `deliver`, the\ncontext for the person's next message. **Send** is there where the prompt port\nhas `send`. The context is built at the press, from what is live then; a changed\ndocument or revision never retargets it. A Quick Edit that reached its destination\n(copied, queued or sent: no `failed` or `cancelled` receipt) tells the host once,\n`usage.used('quickEdit')`, for its count; a host that throws there changes nothing.\n\n`ViewerHost.promptFiles` is a `PromptFiles`: `create({ name, input, views, annotation })`\nanswers `{ inline: compactJson }` or `{ snapshot: absolutePath }`, `read(path)` fetches JSON,\nand optional `save({ name, kind, data })` remains for older clients that export individual\nannotation files. Current chips do not call it. `createHttpPromptFiles({ origin, fetch })` from\n`@text-to-cad/core/client` posts to `POST /__cad/prompt-files`, which validates the packet in memory and returns inline JSON or writes the larger\nsnapshot and returns its path with HTTP 200. The client advertises `acceptInline: true`;\nrequests without it always receive a saved path, so already-open clients remain compatible. There is no render worker\nor polling endpoint. Guarded `GET /__cad/prompt-files/<name>` serves JSON only;\n`POST /__cad/prompt-files/save` supports those older clients. The files\nare scratch in the system's temporary directory and outlive the viewer until the\nsystem clears them. The response and persisted snapshot name input and output\nfiles by absolute path; annotation data is inline, so replay works from another\nworking folder. Both apps pass one; the MCP app's\nreaches the server through its tunnel.\n\n## App-specific interfaces\n\nThe optional CAD `live` binding receives a `CadLiveController` only while its\nviewport is mounted. `readState()` returns a detached serializable snapshot of\nthe actual resource/revision, current selections, camera, display and render\nmode. It reads the viewport camera directly, with its last camera snapshot as\nan unmount fallback. During a rebuild that retains a predecessor mesh, its\ndisplayed document revision remains in the snapshot until replacement. Apps\nown the binding registry and any IPC/tool transport;\nshared UI does not infer view state from a backend catalog or stored tab state.\n\nThe controller selects available selectors, clears selection, applies a camera,\nresets framing, applies grouped View settings, selects presets and captures a PNG.\n`thumbnail({ width, height })` is a library card's picture: it waits for the view\nto settle — the renderer's own live state saying the whole file is loaded and\ndrawn, never a timer — then draws the model framed whole from the default\ndirection at the card's aspect, on its own (no floor, grid or axes, whatever the\nperson turned on) and on transparency, so it suits either scheme, off to the side of the view, so\nthe person's camera, panels and window never show in it and nothing on screen\nchanges (`kit/viewport/thumbnail.js`; a DXF paints its fitted drawing on a canvas\nof its own). A view that goes before it settles rejects it. `useModelThumbnail`\nkeeps one per revision of a file per mounted view (a model rebuilt while it is\nopen is pictured again once its new revision is drawn), and only for the file the\nview still shows.\nOn the home, a card on screen with no picture, or one taken before its file last\nchanged (`pictured` against `modified`), is pictured out of sight wherever the host\nkeeps pictures (`onThumbnail`): `CadViewer` mounts a viewer of its own for it, over\nits own client — its own renderers and live binding, compact, behind the page at the\npicture's size — one model at a time, each once per visit, and only a model whose\nartifact status is already `compiled`, and keeps the picture through `onThumbnail`\nas it keeps the file on screen's. The home never builds anything: an unbuilt model\nkeeps its placeholder until a view shows it.\n`readState().display` and `setDisplaySettings(patch)` use the same sparse display\nschema as snapshots: `mode` selects `solid`, `shaded`, `parts`, `xray`, `hidden-line`,\nor `wireframe` (each a look at its own defaults), `grid` switches the measuring grid,\nand appearance, camera, clip and exploded are shared. Patches merge supplied fields;\na mode has no settings of its own, and a patch naming any is refused. There is no\nCustom state or legacy shape migration. Render is no mode: it is Preview's, which no\nlive command opens, so `mode: \"render\"` is refused as any unknown mode is; Preview's\nown settings are the viewer's panel's, not the display's. Camera projection/lens changes preserve viewport pose and zoom.\n`resetCamera` frames the model again without turning the camera — exactly what\nSTEP's context-menu \"Zoom to fit\" does. Cube shortcuts change only orientation.\n\nA file's view holds its camera: a mount restores it in place of the fit, and fits\nwhen there is none. Live\ncommands reject retired field names rather than maintaining a second display\nauthority. Unavailable selectors fail explicitly; topology is not silently\nloaded. Mutations return a view snapshot after the React frame; a mode switch\nwaits for the requested settings to commit, with a ten-second bound.\nLoading views\nreject commands, and an operation whose resource/revision changes or viewport\nunmounts before completion rejects its late result. On unmount, the controller\nreleases its component closure and retains only its final snapshot with\n`active:false`; every subsequent command requires showing the model tab first.\nHosts may cache that inactive snapshot without retaining a scene or moving focus.\n\n## Files, state and shutdown\n\n`FileSource` reads files; `FileActions` holds the file menu's native operations\n(Copy path, Reveal), and the viewer never writes a file. Both apps read any CAD\nfile on the machine by its absolute path, through the viewer server: what a view\nlists and searches is the server's, and a request for a file that is not there is a\n`cad-file-missing` error (`isMissingFileError`).\n\nFileViewer opens a file's document when the file is shown, and again only on `reload`: a\nrenderer follows its file itself. A CAD renderer (its document from\n`prepareWorkspaceEntry`) reads the live catalog entry, so a content change — or a write\nthat briefly empties the file — loads the next revision behind the model on screen, under\n\"Updating model…\" (a drawing's: \"Updating drawing…\"). Once a model has been shown, a\nrebuild is an update, never the loading screen.\n\nState remains controlled through FileViewer props, and everything the viewer keeps\nis the tab's (`@text-to-cad/ui/tab-store`): one record per tab, `{ version, settings,\nfiles }` (`version` is `TAB_RECORD_VERSION`, 2; a record another version wrote reads as\nthe defaults), thrown out with the tab and kept across a reload. The host supplies where it\nlives through one adapter, `TabRecordStorage` — a synchronous read and write of the\nwhole record: the web over `sessionStorage`, the CAD app over memory\n(`memoryTabRecord`) — and the package owns the record's shape, version and normalization. `settings` is\n`{ toolStack, appearance, library }`: tab-wide (the file sidebar's and tool stack's layout, the\nappearance, the home's layout), and what every renderer reads as its preferences;\n`files` holds the view of the file on screen, under `[absolute path, renderer id]`, and no\nother: a write keeps the newest alone (`TAB_FILE_LIMIT`), and `CadViewer` drops the view\nof a file it leaves — for another file or for the home — once the\ndeparting renderer's last write has landed (`files.retain(path | null)`; `null`, the home,\ndrops every view). The store reads and writes a view (`files.read`, `write`, `remove`),\nlists them all (`all()`) and merges a view's changes (`merge`). A reload of the tab shows\nthe same file, so it brings that view back; a file opened again after leaving it starts\nat the defaults. In the CAD app there is one record per view: a view the host\ncreates again (its frame re-created) starts afresh, since nothing names a view across its\nframes. A view is `{ camera, display, playback, renderer }` (`kit/shell/fileView.js`):\nthe camera is restored in place of the open-time fit, the display settings with their\nClip and Explode, the playback settings (Orbit's speed, Autoplay, and the chosen speed and\nloop), and the renderer's own slices each behind the\nsignature it was written against — a slice that no longer fits the file on screen is\ndropped, the camera, the display and the playback never. Not in it, and started afresh\non every open: the tool in hand, the selection, measurements, ink, Preview, a\nroutine's time and Quick Edit's note. Apps merge\nwhat a view changed into the store (`files.merge`); a stale view must not overwrite\nanother view's entries. Material appearance is source-owned and read-only. Live\nselection and scene ownership belong to the mounted view.\n\nA mounted view writes its view shortly after each change (the camera on every move,\ndebounced) and once more when it unmounts, and nothing after that; the store writes\nthrough synchronously, so what the tab last saw is what a reload restores. Nothing\nsaves document content or promises an asynchronous operation will finish during page\nexit. Web owns pagehide\n(which unmounts the app), focus, visibility, history and reload under a new server. Desktop\nowns window/runtime lifecycle and IPC.\n\nThe dependency checker enforces host boundaries, including worker source. The\nbrowser harness mounts real renderers with explicit fake hosts; app tests cover\nnative/clipboard delivery and multiple-view state merges. Warm-cache regression\ntests remain required for resource changes.\n\n## Keyboard scope\n\nViewer shortcuts consume only events from their own viewer, or from the page\nbackground after a pointer press in it, so another viewer or the composer never\nreceives Escape, copy or orbit keys on its behalf\n([settings-ui.md](settings-ui.md#keyboard)). Annotate's surface reads the host's\nkeyboard `platform` (`environment.platform`) for its undo and redo keys.\n\n## Host chrome slots\n\nFileViewer draws ONE navbar, the same in every app, over every file and over a host's home.\nOver the home it has only the logo and its menu (no Back to files), Back and Forward where\nthe host hands a `history`, and the host's update and Full size, each where it stands over\na file; the home's search, layout switch and Open stay\nin its Recent Files row, and its links under its title. Left: the text-to-cad \"C\" logo,\nwhich opens the app menu (`AppMenu`, `file-viewer/navigation/AppMenu.jsx`), then Back and\nForward where the host hands a `history`, then the open file's name and its ⋯ menu. The\nlogo, Back and Forward, the name and the ⋯ share one style (`NAV_ITEM_CLASS`):\ntransparent at rest, the same accent background while the pointer is on them or their menu\nis open, a pointer cursor, and no tooltip. The name opens the explorer where the host's\nfiles can be browsed (`files.list` and `files.search`), and is plain text where they\ncannot; the ⋯ offers Copy path and Reveal in Finder / Show in Explorer / Show in file\nmanager, each where `fileActions` can do it, and is absent where it can do none (no\nright-click on the name). Right, only what a host or a file adds: the host's update button\nfirst (`update`, while its install is behind), the renderer's navigation actions, the\nhost's Full size where it shows the view small\n(`fullSize`, the inline card's way to full size, over the home too), and last the box the\nrenderer draws its own controls into (over a file only). The navbar holds no settings.\n\nThe app menu is FileViewer's, built from the host's `navigation.home`, `links` and\n`environment.platform` and the person's `FileViewerProps.appSettings` (which `CadViewer`\npasses on), the same over a file and over the home. It is a dropdown on the floating surface.\nFirst **Back to files**, where the host has a home; then\nthe host's on/off settings (`appSettings`, below) as checkbox items, checked at the right,\nwhich a press turns without closing the menu; then, where the host has a tracker\n(`links.issues`), **Send feedback**, a link to a new issue titled \"Feedback: \"\n(`feedbackUrl`, `NavbarLinks.jsx`), for the person to finish, naming the version and\n`environment.platform` — it has no label: the project has none for feedback, and what is\nsaid may be a bug, a request or a question — then **GitHub** and **Discord**; and last, in\ngray, \"v<version> · Made by @<handle>\": the version links its release notes\n(`links.release`) and `MadeBy` the host's X account. The version is the release's own build's\nas it is (`v0.7.15`), and any other build's with its id (`v0.7.15-dev.b80844940`, the commit\nit was built from, `-dirty` with uncommitted changes): `viewerLinks({ version, build })`,\nwhere each app's Vite config names the build through `@text-to-cad/ui/build-id`, and a new\nissue names it the same way. The home also has GitHub, which says the project is open\nsource, Discord and X as icon links under its wordmark (`HomeLinks`), in that order.\n\nAn alert card's Report Issue opens a new issue too,\ntitled \"Issue: \" and labelled `bug`, filled in from the card (`kit/status/reportIssue.js`): its\ntitle, message and failure, the file's name, the version and platform, then its Details, cut from\ntheir end to keep the address, title and labels included, under `ISSUE_URL_MAX`. No path of the\nmachine goes with it: the file's path is its name wherever the card writes it, and a home\ndirectory in anything else is `~/`. A label is a suggestion: GitHub applies a URL's labels only\nfor someone with triage access to the repository and drops them for everyone else. A link opens\nthe ordinary way unless the host supplies\n`links.open` (a page in a sandboxed frame hands it to its host), which every link of the\nmenu follows. The colour scheme is the menu's Theme row (`colorScheme`, a `CadViewer`\nprop), over a file and on the home alike; no host has a light/dark button of its own, and\nDisplay holds no appearance control. `language` (`{ locale, inferred, choice, onChoiceChange }`, a `CadViewer` prop) is the person's\nlanguage for the app menu's Language row: `choice` null is Automatic, `inferred` what Automatic\nmeans; both apps keep the choice in the person's settings (`/__cad/language`, the `language`\nsection of `settings.json`), so it holds for every CAD view. `appSettings` (`{ id, section, label, checked, disabled?, onCheckedChange }[]`,\na `CadViewer` prop) are the host's own on/off settings: checkbox items of the app menu, in the\norder they come (Share anonymous usage data, in both apps), the same in the\nlogo's menu and the home's; nothing of them reaches a renderer, and Display holds none. The\nhost owns what each one does and where it is kept.\n`@text-to-cad/ui/consent` is the usage stats toggle both apps share:\n`useAnalyticsConsent(consent)`, which turns the host's consent call into its `appSettings` row\n(Share anonymous usage data). Nothing asks: cadgen's telemetry is on by default once a `cadgen` command\nhas said so. The host supplies the call and where the answer is kept.\n`@text-to-cad/ui/update` is the update button both apps share, from cadgen's version check:\n`UpdateButton`, the blue button a host hands the viewer as its `update` (`CadViewerProps.update`,\nfirst among the navbar's right-hand controls, an icon, over every file and over the home), which opens its card in a popover, and `useUpdateNotice(call, initial)`, which\nstarts on the notice the host already has (a CAD app's launch carries it; the web page reads it with\nits server's description), so the button draws with the page, and reads the host's call again when\nthe page regains focus. Nothing it does is an answer the server keeps: the button stays while the\ninstall is behind and goes once the update lands. The card sends its prompt\nto the agent's chat through the host's `send`, where the host can, and otherwise copies it through\n`copy`, its button then Copy prompt; the prompt has a copy icon in its top-right corner either way; the host decides which it supplies. Its\nManual installation link, the notice's `instructions`, opens through the host's `onLink`. The shell\nhandles placement and hides the toolbar in\nPreview; the host owns callbacks and preferences. None of these imply platform\ndetection or move application-specific release/network behavior into shared UI.\n\nThe explorer is a popover under the file's name (`FolderExplorer.jsx`;\n[FileViewer](file-viewer.md#the-navbar-and-the-explorer)): it floats over the view and\nnever resizes it.\n\nA file that will not open leaves the navbar over a page of the host's\n(`presentation.error`). `CadViewer`'s says \"File does not exist\" and the file's path\nwhen it is missing (`isMissingFileError`), and \"Could not open that file\" and the\nreason otherwise, each with a Go home button where the host has a home.\n\nThe viewport's edges and corners are the shell's, never the host's: the tool strip at the\ntop centre, ending in the view's own controls (Display and Preview, a 3D view's), the tool in\nhand's sub-toolbar under it, the file sidebar down the left edge, Quick Edit at the\ntop-right, and the view cube at the bottom-right. A host's one notice goes through the shell too: `notice` is drawn at the top-right once the\nfile is on screen, never while it loads or after it failed to, with Quick Edit stacked under\nit while it stands; the home never shows it. Neither app has one now.\nWhat Quick Edit offers follows the subscribed destination capability and\nthe ports, never an app name: Copy Prompt always, Queue for a composer\ndestination, Send where the prompt port has `send`. Native clipboard effects\nremain in the host implementation.\n\nA STEP's selection copy (a chip's click, the copy shortcut) and Annotate's\ncopy shortcut (the view with its annotations) use `ClipboardPort` directly, including on\ndesktop: the references as text, the view with its ink as a PNG through\n`writeImage`. Their copy shortcut follows the same path. A double-click copies\nnothing: on a face or edge it turns the view square to it, on a component or\nsubassembly it isolates it. The host supplies\n`environment.platform` for the ⌘C / Ctrl+C hint; the web host derives that field\nfrom its browser environment.\n\nAn alert card's Details have a copy icon in their box's top-right corner\n(`kit/status/ViewerAlertCard.jsx`): it copies the whole of them, however far they scroll,\nthrough `ClipboardPort.writeText`, and shows a tick for a moment. A host whose clipboard\nrefuses the write gets a line saying so under the box, where the text stays to select; a\nview with no host clipboard gets no icon. Each host makes its port copy wherever it can: the\nweb falls back to the page's copy command when the browser refuses the asynchronous\nclipboard, and so does the CAD app when its host grants its frame no clipboard.\n\nA copied reference names its file by its absolute path (`<path>#<selector>`), and so\ndoes a copied Quick Edit: every view spells a file the same way, so a reference pasted\ninto a chat or a tool says exactly which file it means.\n\nNothing of the viewer sits at the bottom centre. A host whose own control floats over\nthat edge (a chat's composer) sets `--cad-host-bottom-inset` on an ancestor to the height it\ncovers: the model library scrolls its last rows clear of it. A host whose own control\nfloats over the view's bottom-right corner sets `--cad-viewport-corner-bottom` to the room\nit takes from the bottom edge, and the view cube sits that much higher. To choose the\nopposite corner instead, set `--cad-viewport-corner-left: 2px` and\n`--cad-viewport-corner-right: auto`; their defaults are `auto` and the cube's right inset.\nCodex's tab presentation uses the left corner across sidebar, thread and file views.\nThe shared viewer consumes these layout values without identifying the host.\n\nA renderer's update status is its own: the CAD renderers show it centred under the tool\nstrip and its sub-toolbar. The navbar carries none.\n","packages/ui/src/assets/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Fassets%2FREADME.md -->","packages/ui/src/renderers/dxf/__fixtures__/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fdxf%2F__fixtures__%2FREADME.md -->","packages/ui/src/renderers/step/__fixtures__/step/README.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fstep%2F__fixtures__%2Fstep%2FREADME.md -->","plugin.json":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=plugin.json -->","skills/bambu-labs/SKILL.md":"---\nname: bambu-labs\ndescription: Send prints to Bambu Lab printers through Bambu Connect, Bambu Lab's official app for printing from other software, or Bambu Studio. Use when the user wants to print a sliced `.gcode.3mf`, a Bambu `.gcode` or an unsliced model on a Bambu Lab printer, over Bambu Cloud or LAN. The agent opens the file in the app; the user picks the printer and starts the print there.\nlicense: MIT\n---\n\n# Bambu Labs\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nBambu Lab printers take print jobs from other software only through Bambu\nConnect, Bambu Lab's desktop app for third-party tools\n(https://wiki.bambulab.com/en/software/third-party-integration). This skill\nopens a file in Bambu Connect, or an unsliced model in Bambu Studio; the user\npicks the printer and starts the print there. The agent never controls a\nprinter and never asks for access codes.\n\n## Requirements\n\nBambu Connect (Windows 10 or later, macOS 13 or later), signed in to the user's\nBambu Lab account, with the printer on that account or reachable in LAN mode\n(https://wiki.bambulab.com/en/software/bambu-connect). On Linux, where Bambu\nConnect is still in development, use Bambu Studio.\n\n## Choose the handoff\n\n| What the user has | Handoff |\n| --- | --- |\n| A sliced `.gcode.3mf`, from `$gcode` or Bambu Studio's or OrcaSlicer's \"Export plate sliced file\" | Open it in Bambu Connect |\n| A plain `.gcode` sliced with this printer's Bambu profile | Open it in Bambu Connect; if Bambu Connect refuses it, slice the model in Bambu Studio instead |\n| A model with no slice: `.3mf`, `.stl` or `.step` | Open it in Bambu Studio, which slices it and sends it to the printer itself |\n\n## Open a file in Bambu Connect\n\nBambu Connect imports a file from a `bambu-connect://import-file` link with\nthree parameters: `path`, the file's absolute path, and `name`, the name to\nshow, each percent-encoded the way JavaScript's `encodeURIComponent` does it\n(Python: `urllib.parse.quote(value, safe=\"\")`); and `version=1.0.0`. For\n`/Users/me/prints/bracket.gcode.3mf`:\n\n```text\nbambu-connect://import-file?path=%2FUsers%2Fme%2Fprints%2Fbracket.gcode.3mf&name=bracket&version=1.0.0\n```\n\nOpen the link with `open \"<link>\"` on macOS, or `Start-Process \"<link>\"` in\nPowerShell on Windows. Bambu Connect comes forward with the file loaded.\n\n## Open a model in Bambu Studio\n\nOn macOS run `open -a BambuStudio <file>` (some installs name the app\n`Bambu Studio`). On Windows open the file from Bambu Studio's File menu, or run\n`Start-Process <file>` when Bambu Studio is the file's default app. On Linux run\n`bambu-studio <file>`, or open it from the AppImage or Flatpak.\n\n## Finish in the app\n\nBefore the user presses Print, tell them what to check: the printer, plate\ntype, nozzle, filament and AMS mapping, a clear build plate, and someone nearby\nfor the first layer. Never report a print as started: the agent can't see the\nprinter, and the app shows the job and its progress.\n\n## Out of scope\n\nStarting, pausing or cancelling prints without the user, and reading printer\nstatus: Bambu Lab's firmware takes those from other software only through Bambu\nConnect, or in Developer Mode, which is LAN-only and disconnects the printer\nfrom Bambu Cloud. For slicing, use `$gcode` or Bambu Studio.\n","skills/bambu-labs/agents/openai.yaml":"interface:\n  display_name: \"Bambu Labs\"\n  short_description: \"Send prints to Bambu printers via Bambu Connect\"\n  default_prompt: \"Use $bambu-labs to send my sliced print to my Bambu Lab printer through Bambu Connect.\"\n","skills/cad/SKILL.md":"---\nname: cad\ndescription: Create/edit parametric CAD models, organize CAD projects, export STEP/STL/3MF/GLB files, resolve prompt references, and measure geometry with cadgen. Open and visually review existing STEP/STP, STL, 3MF and GLB files in CAD Viewer.\nlicense: MIT\n---\n\n# CAD modeling and inspection\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files for the current interface.\n\n## Start with the task\n\nRead only the references needed for the request.\n\n| Task | First action | Reference |\n| --- | --- | --- |\n| **Create or edit a part or assembly** | Find the existing Python model, or create a decorated model below; edit source and run `python <model>.py`. | [Model contract](references/step-generation.md), [shape construction](references/build123d-modeling.md); [positioning](references/positioning.md) for assemblies |\n| **Organize a CAD project** | Follow its existing layout; for a new multi-model project use `src/`, format output folders, and a model catalog. | [Project layout](references/project-layout.md), [minimal starters](references/project-template.md) |\n| **Export STL, 3MF or GLB** | Add a mesh decorator for a maintained output, or run the format's `build INPUT.step OUT` command for a one-off export. | [Mesh exports](references/supported-exports.md) |\n| **Resolve a reference from a prompt** | Identify its saved STEP/STP document, open it with `read_scene`, and call `scene.resolve(ref)` as shown below. | [Reference syntax and inspection](references/inspection-and-validation.md#reference-syntax) |\n| **Measure or check geometry** | Write a Python check using native build123d geometry and, where useful, `cadgen.geometry`. | [Inspection and validation](references/inspection-and-validation.md) |\n| **Model from an image or drawing** | Extract the specified dimensions and record meaningful assumptions. | [Interpreting the request](references/cad-brief.md) |\n| **Open an existing STEP/STP, STL, 3MF or GLB** | Show it to the user. | [Show the model](#show-the-model) |\n| **Review appearance or motion** | Snapshot the saved document; use declared kinematics or animation for poses and clips. | [Snapshots](references/snapshot-review.md), [kinematics](references/kinematics.md) |\n| **Diagnose a failure** | Read the error and check the relevant model, geometry or command contract. | [Repair loop](references/repair-loop.md), [version migration](references/migrations.md) |\n| **A message says to migrate** | Do the migration now; an unmigrated model silently loses kinematics, materials and animation. | [Version migration](references/migrations.md) |\n\nFor 2D DXF drawings use `$dxf`; this skill owns any 3D part the drawing projects.\nUse the corresponding robot-description skill for URDF, SRDF or SDF.\n\n## Setup and paths\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors. Use the relevant\nsubcommand's `--help` for additional flags.\n\nRun project commands from the CAD project root. CLI input/output paths and\n`read_scene`/`read_step` paths are working-directory-relative; decorator `out=`\npaths are **relative to the model script**. Anchor file inputs on `__file__`\nwhen the model must run from any directory.\n\n## Create or edit a model\n\nA model is a plain Python script with a parameterless decorated function\nreturning a build123d shape. Use one model per entrypoint, with the script and\nits declared outputs sharing a filename stem. For example, `src/bracket.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 40.0\n\n\n@step(out=\"../STEP/bracket.step\")\ndef bracket():\n    body = bd.Box(WIDTH, 20, 6)\n    body.label = \"bracket\"\n    return body\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython src/bracket.py\n```\n\n- Edit the model source when it exists, then run it to regenerate its outputs.\n  Document export and snapshot commands take saved files and never run source.\n- Keep meaningful dimensions explicit. Use millimeters and XY/+Z unless the\n  task or project specifies another convention; choose a useful functional datum.\n  Prefer closed, positive-volume solids for physical parts, while honoring\n  requests for surfaces or construction geometry.\n- Put parameterized geometry in ordinary factory functions; a decorated model\n  selects a configuration. Keep module bodies cheap: create geometry and read\n  CAD inputs inside the model or its helpers. Use the lazy `bd` import above;\n  use postponed annotations when annotations mention `bd` types.\n- Call child models inside the assembly model. Place their results with\n  `.moved()` or `Location * shape` to preserve shared geometry. Use meaningful\n  occurrence labels and source-defined placements. Rerun the parent assembly\n  to incorporate a changed child.\n- Read vendor STEP inputs with `cadgen.read_step`. Every file a build opens is\n  an input on its own, whatever reads it (`json.load`, `np.load`,\n  `bd.import_step`, a project font): nothing is declared. Never read a model's own\n  output as its input. Geometry must not depend on untracked\n  time, random values, environment variables or the working directory.\n- When named purchasable parts are needed, search `$step-parts` before making\n  placeholders. Record an unsuccessful search and any placeholder assumptions.\n\nFor unfamiliar dimensions or interfaces, record the assumptions needed to model\nand verify them. Ask for missing information when it materially affects the\nrequested result. Inspection and export requests do not need a modeling brief.\n\n## Mesh exports\n\nStack `@stl`, `@threemf` or `@glb` on the model for outputs that should be\nmaintained on every run. A model may declare only meshes; STEP is optional.\nFor a one-off export from an existing generated or imported STEP:\n\n```bash\ncadgen stl build STEP/bracket.step STL/bracket.stl\ncadgen 3mf build STEP/bracket.step 3MF/bracket.3mf\ncadgen glb build STEP/bracket.step GLB/bracket.glb\n```\n\nOmitting OUT writes one sibling file with the requested extension. It does\nnot discover declared model variants. See [mesh exports](references/supported-exports.md)\nfor decorator examples, mesh tolerances and animated GLB.\n\n## Prompt references and inspection\n\nA reference such as `/work/robot/STEP/assembly.step#o1.2.f7` identifies geometry\nin a particular saved document. Its file part is the document's absolute path as\nthe CAD viewer copied it, with the file's real name and extension (in quotes when it\nholds a space or `#`). A selection of several entities is copied as one line, its\nrefs comma-separated (`/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8`). Open that\npath with `read_scene`, and pass the whole line to `resolve()`, which returns a tuple\nof one selection per ref, in order:\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"/work/robot/STEP/assembly.step\")\nfor selection in scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8\"):\n    face = selection.shape()  # owned native geometry, in document world coordinates\n    print(selection.ref, face.area)\n\n(selection,) = scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7\")  # one ref\n```\n\nA Quick Edit prompt names its document under `File:`, selected entities under\n`References:`, and snapshot context as inline JSON or a `.snapshot.json` path.\nTo render, save inline JSON to a temporary `.snapshot.json` and run\n`cadgen snapshot --job <snapshot file>` (`--job` takes a file path).\nInspect supplied annotations before editing. Without annotations, the captured\nview is optional context; render it when useful to the request. After editing,\nreplay relevant views to check the change. See\n[annotations and Quick Edit views](references/snapshot-review.md#annotations-and-quick-edit-views)\nfor the packet schema and capture behavior.\n\nA reference into a file that is not a STEP names what the viewer picked in it, in that\nfile's own terms (none is a STEP selector `read_scene` resolves):\n\n| File | Reference | Means |\n| --- | --- | --- |\n| URDF, SRDF, SDF | `/work/arm/arm.urdf#upper_arm` | the link `upper_arm` |\n| GLB | `/work/out/model.glb#n12` | the glTF node `nodes[12]` (0-based, the file's own index) |\n| 3MF | `/work/out/plate.3mf#o2.1` | the 2nd `<build><item>`'s 1st `<component>` (1-based); `#o3` is the 3rd build item |\n| STL | the file alone, on `File:` | one mesh: nothing in it is picked |\n\n\nFor a bare `#o1.2.f7`, use the identified target file. Do not guess between\nambiguous files or labels. Numeric refs belong to that saved revision;\nreopen and reselect after rebuilding. The [inspection reference](references/inspection-and-validation.md)\ncovers label aliases, enumeration, measurements and small reusable operations.\n\nThere is no inspect CLI. Put exploratory checks in the project's ignored\n`tmp/` (or system `/tmp/`); retain reusable checks in `checks/` or its existing\ntest directory. Keep them outside model-source and raw-output folders.\n\n## Verify and hand off\n\nChoose checks from the requested dimensions, clearances and topology. For STEP\noutputs, check the saved artifact with `read_scene` or `read_step`. For mesh-only\nmodels, check the model's returned native geometry and review the mesh output;\ndo not add a STEP solely to satisfy the workflow. Report units, thresholds,\nselected geometry and untested requirements. A failed computation is not a pass.\n\nAfter creating or visibly changing geometry, generate and review at least one\nsnapshot of the resulting STEP or mesh. Snapshots are your own review: always\nrender and read them yourself, never rely on the viewer for it. Choose additional\nviews to expose the features under review; see\n[snapshot policy and options](references/snapshot-review.md).\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\ncadgen stl snapshot STL/bracket.stl tmp/mesh.png\n```\n\nRepair failures in the source and rerun the affected checks. Use geometry and\nimages for CAD comparisons; path-targeted git status is bookkeeping, not\ngeometric evidence. `cadgen store why <model>.py` explains unexpected rebuilds;\n`python <model>.py --force` forces one model, and `cadgen daemon status` shows\nbuild progress. More diagnostics are in the [model contract](references/step-generation.md).\n\nInclude output files, checks actually run, and material assumptions or\nlimitations in the final response. The user sees the model in the viewer (Show\nthe model), so don't attach snapshots unless they ask for an image. Explain any\nsnapshot skip or failure using the cases in the snapshot reference.\n\n### Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nGenerate changed artifacts first: the viewer never runs model scripts. Existing\nSTEP files compile on open when needed. Exact topology selection and measurement\nrequire STEP; supported mesh viewers also offer approximate measurements on triangles.\n","skills/cad/agents/openai.yaml":"interface:\n  display_name: \"CAD\"\n  short_description: \"Generate and validate CAD artifacts.\"\n  default_prompt: \"Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","skills/cad/references/animation-deformation.md":"# Tube deformation and morph export\n\nRead this reference for flexible swept bodies in an embedded animation, or for\nexporting that deformation into GLB. The [motion reference](kinematics.md)\ncovers ordinary joints, poses, clip declarations and video rendering.\n\n## Deforming an existing tube\n\nCall `.deformTube()` on an animation handle for a continuous swept STEP body:\n\n```javascript\n// Inside update(t, m), with restPath and posedPath defined for this frame:\nm.get(\"tendon\").deformTube({\n  rest: restPath,\n  path: posedPath,\n  twistDeg: 0,\n  maxSegmentLength: 1,\n});\n```\n\nBoth paths are `{normal, segments}` in assembly coordinates. `normal` is a\nrequired transverse frame seed on each path. Segments must connect tangentially:\n\n| Segment | Fields |\n| --- | --- |\n| Line | `{kind: \"line\", start, end}` |\n| Arc | `{kind: \"arc\", center, axis, start, sweepDeg}` |\n| Cubic Bezier | `{kind: \"bezier\", points: [p0, p1, p2, p3]}` |\n\nPositions are vec3 millimetres; angles are degrees. Normalized arc length maps\nthe rest path to the posed path. Check tendon length, bend radius and collisions\nseparately: deformation does not solve those constraints. `twistDeg` rotates\nauthored cross-sections; it does not calculate spool payout.\n\n`maxSegmentLength` controls one-time longitudinal mesh refinement in millimetres\n(default `1`, minimum `0.05`). The rest mesh, normals and topology remain\ncontinuous, and shared source buffers remain immutable. Omitting deformation in\na later frame restores the rest shape.\n\nOptional `braid: {pitch: 0.8, depth: 0.02, strands: 8}` adds procedural fiber\ncolor and normal relief over the swept core. Pitch and depth are millimetres;\nthe strand count must be even. This is a surface finish, not additional CAD or\ncollision geometry.\n\n## Exporting deformation to GLB\n\nAnimated GLB refuses tube deformation by default. Request morph targets explicitly:\n\n```bash\ncadgen glb build STEP/hand.step GLB/animated/hand.glb \\\n  --animation '{\"clip\": \"fist\", \"fps\": 24, \"seconds\": 6,\n                \"deform\": \"morph\", \"deformTolerance\": 1.0}'\n```\n\nEach deforming node gets per-vertex position and normal deltas against one base\nmesh, plus a weights channel. Interpolation between baked poses can differ from\nthe procedural deformation. The exporter fits targets per occurrence against\n`deformTolerance` (millimetres, default `1.0`, range `0.01`..`10`), checking a\ngrid four times finer than `fps`, at least 96 Hz. This sampled check is not a\ncontinuous-time error guarantee. The export summary reports `targets`, `bytes`,\n`runtimeBytes`, `deviationMm` and `fitGridHz`.\n\nThe exporter refuses a bake exceeding 512 MiB of estimated morph playback\nmemory: 32 bytes per vertex per target, or 16 without normals. To reduce it,\nrelax `deformTolerance`, shorten `seconds`, increase `--mesh-tolerance`, or\nincrease the clip's `maxSegmentLength`, as the required fidelity permits.\n\nLimitations:\n\n- Procedural braid shading is not exported. The GLB retains the tube's geometry\n  and motion with a smooth surface.\n- Changing a tube's `rest` path during the clip is refused because morph targets\n  share one base mesh. Use `.rotate()` or `.translate()` for rigid motion.\n- A tube held in a constant deformed pose exports in that pose without morph\n  targets. `\"deform\": \"rest\"` instead explicitly freezes tubes at rest and warns;\n  use it only when that is the intended output.\n\nThe CAD Viewer plays baked morph animation from GLB, but the original clip's\nprocedural controls remain in the STEP sidecar. Render a video when the output\nneeds effects GLB cannot carry; see [motion review](kinematics.md#reviewing-motion).\n","skills/cad/references/build123d-modeling.md":"# build123d modeling patterns\n\nRead this file when constructing or repairing native CAD geometry. Model scripts\nreturn build123d shapes; decorators declare the output files. See the\n[model contract](step-generation.md) for execution and composition.\n\n## Construction choices\n\nChoose a construction that expresses the controlling dimensions directly and\npreserves the intended geometry. Builder contexts and algebraic modeling are\nboth valid; profile operations, solid features and surface construction suit\ndifferent shapes. The examples and remedies below are options, not a required\nfeature sequence.\n\nUse meaningful parameters and functional datums. Keep separately manufactured\nor moving parts identifiable in an assembly; fuse bodies where the design calls\nfor one continuous part. Prefer solids for physical parts, but do not close or\nthicken surfaces when the user requested surface geometry.\n\nOperation order depends on the design. Applying finishing features late often\nmakes selectors simpler; constructing them in a profile can be more robust for\nsome outlines. Through-cut tools should span the intended material; choose any\nextra extent relative to the geometry and nearby features, not a fixed distance.\n\n## Selection and topology\n\nAn **occurrence** is a placed assembly node. Its geometry contains bodies,\nfaces, edges and vertices. For inspection, keep the canonical `ref` and\n`occurrence_ref`; see [inspection](inspection-and-validation.md) for enumeration.\nNumeric selectors belong to one saved revision.\n\nFor construction, select by the feature's geometry or datum where practical:\nnormal, axis, plane, position or curve type. Re-evaluate selections after an\noperation that changes the relevant topology. Select a fillet's or chamfer's\nedges from the solid it rounds, after that solid's last operation: an\noperation returns new native topology wherever it changed the solid, so an\nedge held from before it may not be the solid's own. A fillet silently skips\nsuch an edge, and fails when none is left; a chamfer fails on it. Shape\nequality is build123d's own and compares native handles, so\n`edge in solid.edges()` is true only for the solid's own edge.\n\n## Labels and assemblies\n\nUse concise, meaningful native labels on exported parts and assembly occurrences,\nincluding repeated placements such as `m3_screw:front_left`:\n\n```python\n# Inside an assembly model, after constructing and placing its parts:\nbase.label = \"base\"\nlid.label = \"lid\"\nassembly = bd.Compound(children=[base, lid], label=\"electronics_enclosure\")\n```\n\nLabels describe role or placement; they need not repeat topology categories.\nA feature fused into or cut from a body does not retain a separate occurrence\nlabel. Keep its intent in parameters, construction datums and geometric checks.\nDo not promise persistent face/edge names through STEP export.\n\nSee [positioning](positioning.md) for assembly datums, joints, explicit transforms\nand checks of saved mating relationships. Source joints position geometry;\n[kinematics](kinematics.md) separately declares motion for the viewer.\n\n## Colour and finish\n\nNative `Color` channels are linear RGB. Use `cadgen.srgb` for a colour specified\nas a display hex value:\n\n```python\nfrom cadgen import srgb\n\nbody.color = srgb(\"#2E3742\")\nglass.color = srgb(\"#38414D\", 0.42)\n```\n\nSet native colour on leaf occurrences: a colour on a group compound does not\npropagate to its leaves in the render tree. Named material assignments can target\na group and expand to its leaves:\n\n```python\n@step(materials={\n    \"definitions\": {\n        \"cast\": {\"name\": \"As-cast steel\", \"roughness\": 0.85, \"metalness\": 0.2},\n        \"ground\": {\"name\": \"Ground steel\", \"roughness\": 0.25, \"metalness\": 0.9},\n    },\n    \"assignments\": [\n        {\"targets\": [\"#housing\"], \"material\": \"cast\"},\n        {\"targets\": [\"#journal\"], \"material\": \"ground\"},\n    ],\n})\ndef gearbox():\n    ...\n```\n\nMaterial keys are optional `name`, `baseColor`, `roughness`, `metalness`,\n`clearcoat`, `clearcoatRoughness`, and `opacity`. Numeric channels are finite\n0..1 values; `baseColor` is `#RRGGBB`. STEP's sidecar carries these finishes;\n`baseColor` does not change STEP colours or geometry bytes. Finishes inherit\nthrough cached child composition and are consumed by Render and GLB export.\nNormal CAD display keeps colour/opacity with workbench shading. Dynamically\nsetting `cad_material` is unsupported.\n\n## Placement and frame pitfalls\n\n- **Rotation frame:** do not infer a local rotation axis from Euler-angle\n  spelling. Check the transformed basis for a non-global `Plane`; explicit\n  direction vectors or an axis-angle construction can make the intended frame\n  clearer. A valid loft can still join incorrectly oriented sections.\n- **Primitive alignment:** `align=None` preserves the primitive's raw datum;\n  it does not mean centered. For example, a cylinder is based at Z=0 while a\n  box starts at a corner. Set alignment deliberately when placement depends on it.\n- **Absolute versus relative placement:** `.located(loc)` replaces location;\n  `.moved(loc)` and `Location * shape` compose it. Applying `.located()` after\n  a rotation can therefore discard the orientation. For placed child models,\n  use `.moved()` or multiplication to preserve shared geometry; `.located()`\n  copies it and loses the child link. Absolute placement remains a native API\n  option when replacement and copying are actually intended.\n\n```python\n# A local box, rotated before placement:\nbox = bd.Solid.make_box(1, 1, 1)\nrotated = box.rotate(bd.Axis.Z, 90)\nplaced = bd.Location((5, 0, 0)) * rotated  # retains the rotation\n```\n\nIn build123d 0.11.1, a moved assembly compound's `intersect()` can traverse\nunmoved `.children` despite `.wrapped` carrying the correct placement. Extract\nplaced `.solids()` or operate on the placed OCCT shapes when affected.\n`cadgen.geometry.overlap_volume` takes individual solids and queries private\ncopies of their placed geometry. Check the actual pose when diagnosing an\nunexpectedly constant interference result.\n\n## Loft and sampled-profile pitfalls\n\nFor sampled sections, keep feature order, edge correspondence and orientation\nconsistent across stations. Twisting or rippling can be present in valid\ngeometry, so check the intended sections and review the surface.\n\n- Corresponding feature rails or consistent samples per band can prevent a\n  feature from drifting between sections. Equal sample counts alone do not\n  guarantee a correct loft.\n- Independently easing every interval to zero slope can introduce unintended\n  flat spots. Choose interpolation for the desired continuity. Smooth measured\n  noise only within the task's geometric tolerance.\n- A disconnected or self-crossing section can produce misleading downstream\n  loft failures. Inspect section wires as well as face validity. Increasing\n  prefixes or adjacent section pairs can localize a failing region.\n- A ruled loft can help diagnose a smooth-loft failure, but changes the surface;\n  use it as a replacement only if its faceting and continuity meet the design.\n- For fields built by blending sampled component profiles, a component ending\n  inside its neighbor may leave a steep wall. Inspect the blend function and\n  continuity before increasing sample density. Adjust component overlap or the\n  blend only if it preserves the required boundary.\n\n## Boolean and finishing pitfalls\n\nKernel behavior depends on topology, tolerances and the installed build123d/OCP\nversion. Diagnose the failing operation before changing the construction.\n\n- **Many tools:** batch independent cuts when that avoids repeatedly rebuilding\n  the same intersection network. Overlapping tools may need staged operations;\n  sequential cuts are legitimate when later features depend on earlier ones.\n  Restrict excessive tool extents when they create unnecessary intersections.\n- **Large spline surfaces:** even batched cuts can be expensive. Localize the\n  expensive operation with timing or a stack sample, then consider simpler\n  surfaces, smaller tool regions or a different construction of the same feature.\n  A groove's screen size is not a reason to remove it from the CAD model.\n- **Near tangency or coincident boundaries:** inspect the removed/added region\n  and solid count, not only a successful return. A direct profile construction\n  may avoid an unstable Boolean intersection, when it describes the same shape.\n- **Fillets and chamfers:** check selected edges and available local space.\n  Smaller radii, different feature ordering, grouped selections or a profile\n  bevel are possible remedies, subject to the required dimensions. Do not\n  silently reduce or omit a specified feature through a retry ladder.\n- **Tangent chains or complex outlines:** some kernel versions fail or crash\n  during finishing operations. Isolate a reproducible case; a profile bevel or\n  separately constructed transition is a workaround, not a ban on 3D fillets.\n- **2D algebra:** inspect intermediate types. A `ShapeList` participates in\n  Python list operations, which may concatenate instead of fusing geometry.\n  Check wire winding/face normals before extrusion, especially after mirroring\n  sampled points. Confirm an intersection is nonempty before using it as a cut.\n- **Dense periodic splines:** build123d 0.10/OCP 7.9 have shown failures in taper,\n  inward offset and coincident-face fusion for some densely sampled profiles.\n  If reproduced, simplify the representation or construct the offset/transition\n  explicitly and verify its distance, continuity and topology. Numerical offsets\n  are an option, not a universal replacement for kernel offsets.\n\nPreserve the requested geometry while repairing it. If an exact feature cannot\nbe built, report the limitation or seek a design decision rather than quietly\nsubstituting an approximation.\n\n## Validity and visual artifacts\n\nTopology validity does not prove positive orientation, the requested shape, or\nsuitability for a later Boolean. Check signed volume per intended solid;\naggregate volumes can hide inverted members. Open shells are legitimate when\nsurfaces were requested.\n\nUse `cadgen.geometry.topology_errors`, `boundary_edges` and, when relevant,\n`self_intersections` on saved geometry. During a failing construction, check\nintermediates around the suspect operation. A validity gate inside a model\nbody — a retry ladder that accepts a fillet only when the result is sound, a\nstage check on a casting — uses build123d's `shape.is_valid` and\n`cadgen.geometry.is_sound` (the `BRepAlgoAPI_Check` verdict, which also\nidentifies Boolean-suitability issues such as tiny edges). A check costs\nkernel time, so gate where a failure is plausible rather than after every\nsimple operation. Any repair must preserve the dimensions being checked. See\n[inspection](inspection-and-validation.md).\n\nA periodic cylinder or revolved face has a seam edge that may appear in CAD\nlinework. Use shaded display or another camera to distinguish a display seam\nfrom a crack. Do not rotate a finished, datum-constrained part solely to hide\nits seam.\n\nFor further failure diagnosis and before/after checks, see [repair loop](repair-loop.md).\n","skills/cad/references/cad-brief.md":"# Interpreting a CAD request\n\nUse this reference when modeling from prose, reference images or technical\ndrawings. Record only the dimensions, interfaces, outputs and assumptions needed\nto construct and check the requested geometry. A simple part may need a sentence;\na complex assembly may benefit from a dimension/interface checklist. There is\nno required brief format, and direct inspection or export needs no modeling brief.\n\nClarify missing or conflicting information when it materially affects the result,\nespecially a mating interface or required scale. Otherwise proceed with explicit\nassumptions. Do not ask the user to fill out a template.\n\n## Reference images\n\n- Distinguish reproducing a part from using an image as design inspiration.\n- Establish scale from a supplied dimension or known reference when available.\n  Unscaled images can guide proportions; they do not establish fit dimensions.\n- Record inferred dimensions as assumptions. Ask for scale when fit depends on it.\n- For reproduction, compare a snapshot from a similar viewpoint with the image.\n\n## Technical drawings\n\n- Read units, projection convention, revision and notes; identify each view and\n  section before mapping dimensions to model axes.\n- Prefer explicit dimensions over image proportions. Resolve conflicting\n  dimensioned sources rather than silently choosing one.\n- Preserve multiplicity (`4X`), `TYP.`, threads, counterbores, countersinks and\n  internal depths shown in sections. Track the requirements for later checks.\n- Derive unspecified geometry from constraints where possible. Treat dimensions\n  estimated from a drawing image as assumptions, never as verified callouts.\n- Verify the specified dimensions against saved geometry using Python\n  measurements, or explicitly report those not verified. See\n  [inspection and validation](inspection-and-validation.md).\n\nBefore modeling, identify the relevant source/output paths, units, coordinate\nconvention and functional interfaces. Choose construction and validation methods\nthat make the controlling dimensions easy to express and check.\n","skills/cad/references/inspection-and-validation.md":"# Inspecting and checking saved CAD in Python\n\nUse small Python scripts against the saved STEP/STP. `cadgen step inspect`\nand `cadgen.step.inspect` have been removed. Choose the relevant entities,\nmeasurements and acceptance criteria in the script; there is no replacement\ninspection command or universal validation report.\n\nPut exploratory scripts in the CAD project's ignored `tmp/`, for example\n`tmp/check_clearance.py`; system `/tmp/` is also fine for disposable work.\nKeep reusable design checks in `checks/` (for example `checks/clearance.py`),\nor use the project's existing test location. Commit those checks so they can\nbe rerun after a model changes. Create the directory only when needed.\nKeep checks outside `src/` model directories and raw format folders such as\n`STEP/`. Examples below use paths relative to the CAD project root; run from\nthere, or anchor input paths explicitly when a script must run elsewhere.\n\nReport what was measured, the units, the threshold and the selected geometry.\nA failed computation is not a passing check, and an untested relationship has\nno verdict. Run the model first when you want to check newly authored geometry.\n\n## Opening and selecting geometry\n\n```python\nfrom cadgen import read_step, read_scene\n\nshape = read_step(\"STEP/bracket.step\")  # native build123d geometry\nscene = read_scene(\"STEP/assembly.step\")  # hierarchy and exact selector refs\n\nprint(scene.document_hash)\nfor occurrence in scene.leaves():\n    print(occurrence.ref, occurrence.label, occurrence.prototype_id)\n\n(housing,) = scene.resolve(\"#housing\")\nprint(housing.ref, housing.kind)\nsolid_or_compound = housing.shape()\nfor face in housing.entities(\"face\"):\n    print(face.ref, face.shape().area)\n```\n\n`read_step` keeps its existing name. `read_scene` replaces the old\n`load_step_scene` helper. Inside a model, the document either reads is a build\ninput, as every file a build reads is. Paths expand `~` and resolve relative to the working directory.\n\n- `scene.roots` and `occurrence.children` are tuples. `scene.leaves()` yields\n  geometry occurrences, including repeated copies. A leaf can hold multiple\n  solids; the reader does not infer manufactured parts.\n- `scene.resolve(ref)` returns a tuple of selections, one per ref in the line\n  (see [Reference syntax](#reference-syntax)). A selection has `ref`, `kind`,\n  `occurrence_ref`, `shape()` and `entities(kind)`. Kinds for enumeration are\n  `\"shape\"`, `\"face\"`, `\"edge\"`, `\"vertex\"`. Shape entities (`sN`) are solids,\n  or shells when no solids exist, or the leaf geometry itself.\n- Every `shape()` is caller-owned exact build123d geometry in the document's\n  world coordinates, including ancestor placements. A group produces an\n  unfused compound. Modifying the result cannot change the scene or another\n  selection. Keep the returned shape when making several queries about it.\n- `prototype_id` identifies shared geometry within this scene, allowing a\n  script to avoid repeating a suitable body-local check. It is not a part\n  number or an identity across revisions. Numeric self-intersection tests can\n  behave differently at different placements; state which placements ran.\n- A scene is bound to one document hash. Replacing the file does not change an\n  already opened scene. Reopen it to inspect the new revision. Selector IDs\n  are revision-scoped, not persistent feature names.\n- Opening reuses the canonical BREP cache, decoding prototypes on demand.\n  No display surfaces, tessellation, source discovery or source execution are\n  needed. Cached inventory and occurrence lookup defer the build123d import\n  until native geometry is requested. Keep the scene open: repeated reads\n  still hash the document bytes, and fresh Python processes pay import costs.\n- The scene describes the saved placement. It does not evaluate sidecar\n  kinematics or animation poses.\n\n### Reference syntax\n\n```text\n#o1.2          occurrence or subassembly\n#o1.2.f7       canonical face 7 on that leaf occurrence\n#housing.e3   canonical edge 3 on the occurrence labelled housing\n#f7           face 7, only when the scene has exactly one leaf\npart.step#o1  a file-prefixed ref matching the opened STEP\npart.step#o1.2.f7,o1.2.f8  several refs, as the viewer copies a multi-selection\n#o1.2.f7,f8   a bare entity after an occurrence or label belongs to it\n```\n\n`resolve()` takes a whole copied line, one ref or a comma list, and returns a\ntuple with one selection per ref, in order; any ref that does not resolve\nraises. Unpack a single ref: `(face,) = scene.resolve(\"#o1.2.f7\")`.\nTo enumerate a subassembly's faces, resolve it, `(group,) = scene.resolve(\"#group\")`,\nand call `group.entities(\"face\")`; a group has no independent\nface ordinals. Enumeration preserves canonical occurrence IDs and never\nmerges coincident instances. Face and edge IDs agree with the viewer; vertex\nIDs enumerate native STEP vertices (the viewer currently exposes no vertex\ntable).\n\nLabels use letters, digits, `_` and `:`, and cannot start with a digit or\ncollide with numeric reference syntax. Other labels remain accessible through\nnumeric IDs. Duplicate labels receive numbered aliases in occurrence order;\nresolving the ambiguous bare label raises and lists candidates. No fuzzy\nmatching or choosing the first match.\n\nA copied reference's file prefix is the file's absolute path, with the file's real\nname and extension (in quotes when it holds a space or `#`). `resolve()` reads the\nprefix as a path, with `~` expanded, links followed and a relative one read from the\nworking directory, and it must name the opened document.\nA relative prefix that names no file from where you run still matches the end of\nthe document's path. A prefix naming a different document is rejected. Do not\nguess between ambiguous files.\n\n## Measurements\n\nMost measurements already have native build123d interfaces:\n\n```python\n# p, q: chosen points; u, v, direction, normal: explicit vectors\nlength = (q - p).length\nsigned_offset = (q - p).dot(direction.normalized())\nangle_degrees = u.get_angle(v)\nsigned_angle_degrees = u.get_signed_angle(v, normal)\n\n# Select the actual geometric feature that defines the dimension.\ncenter = circular_edge.arc_center\nradius = circular_edge.radius\ndiameter = 2 * radius\nlength = edge.length\narea = face.area\nvolume = solid.volume\nsize = shape.bounding_box().size\n```\n\nA bounding box dimension is an envelope, not a hole diameter or wall\nthickness. A surface centroid is not necessarily a datum. Use analytic\ncenters, axes and selected points when those define the requested dimension.\nThe default `bounding_box()` searches for tight bounds and can be expensive\non curved geometry. Use `bounding_box(optimal=False)` for a fast conservative\nenvelope when filtering candidate collision pairs, not for a tight dimension.\n\n```python\nfrom cadgen.geometry import closest_points\n\nresult = closest_points(a, b)\nprint(result.distance, result.point_a, result.point_b)\n```\n\n`closest_points(Shape, Shape) -> ClosestPoints` returns a nonnegative minimum\nset distance and one witness pair in the input frame. Solid overlap, contact\nand containment have distance zero; it does not measure penetration depth.\nWitnesses may be nonunique or internal to a solid. Pass selected faces/shells\nwhen asking about boundary separation. Minimum distance does not replace\ncenter spacing, angles, radii, lengths, areas or volume measurements.\n\n## Overlap and clearance checks\n\n`overlap_volume(Solid, Solid) -> float` measures the exact intersection volume\nof two finite, positively oriented solids. It returns zero for mere contact.\nIt applies no minimum volume, exclusions, part hierarchy or pass/fail policy.\nKernel failures raise; inputs remain unchanged. Length units from STEP are mm,\nso overlap volume is mm³. Tolerances in these scripts are design decisions,\nseparate from the kernel's numerical precision.\n\nFor example, checking every body pair inside a chosen subassembly:\n\n```python\nfrom itertools import combinations\nfrom cadgen import read_scene\nfrom cadgen.geometry import overlap_volume\n\nscene = read_scene(\"STEP/assembly.step\")\n(selection,) = scene.resolve(\"#housing_assembly\")\nbodies = [\n    (entity.ref, solid)\n    for entity in selection.entities(\"shape\")\n    for solid in entity.shape().solids()\n]\nif len(bodies) < 2:\n    raise ValueError(\"This interference check needs at least two bodies\")\n\nmax_overlap_mm3 = 0.01  # chosen for this design\nfailures = []\ntested = 0\nfor (ref_a, a), (ref_b, b) in combinations(bodies, 2):\n    volume = overlap_volume(a, b)\n    tested += 1\n    if volume > max_overlap_mm3:\n        failures.append((ref_a, ref_b, volume))\nprint({\"tested_pairs\": tested, \"limit_mm3\": max_overlap_mm3,\n       \"overlaps_mm3\": failures})\nif failures:\n    raise SystemExit(1)\n```\n\nAdapt pair selection to the question: for clearance around a moving carriage,\ncompare that carriage against relevant fixed geometry. List intentional\npress fits/excluded pairs explicitly. Do not infer exclusions from assembly\ndepth. For large pair sets, retain the shapes and compute conservative bounds\nonce per body. Disjoint bounds rule out overlap; intersecting bounds only\nidentify candidates for the exact query. For a clearance requirement use\n`closest_points(a, b).distance` and\ncompare against the specified minimum. A static check proves only that pose;\npath or motion checks need an explicit sampling or swept-volume strategy.\n\n## Geometry diagnostics\n\n```python\nfrom cadgen.geometry import is_sound, topology_errors, boundary_edges, self_intersections\n\nok = shape.is_valid                       # bool: BRepCheck passes (build123d)\nclean = is_sound(shape)                   # bool: the boolean kernel's argument check passes, expensive\nissues = topology_errors(shape)            # tuple[GeometryIssue, ...]\nfree = boundary_edges(shell)              # tuple[Edge, ...]\ncrossings = self_intersections(shape)     # tuple[GeometryIssue, ...], expensive\n```\n\n`GeometryIssue` has `code` and `entities`, containing owned build123d geometry.\nTopology codes are OCCT `BRepCheck_*` statuses; self-intersection codes are\n`BOPAlgo_SelfIntersect`. Failed/inconclusive checks raise `GeometryError`,\nnever an empty success result. None of these functions repairs geometry.\n\n`is_sound` is `BRepAlgoAPI_Check`'s verdict: BRepCheck-valid, no\nself-intersections, no too-small edges, an argument type a boolean accepts.\nIt is the gate a fuse or cut demands of an operand. Closure, solid count and\nsigned volume stay the script's own checks. `is_sound` is `False` for a null\nor empty shape, which the boolean kernel rejects as an argument. A check costs\nkernel time, so gate where a failure is plausible rather than after every\noperation.\n\nChoose checks appropriate to the artifact. For an intended closed solid,\ncheck topology, free shell edges and each solid's signed volume. A reversed\nsolid can pass topology validation but have negative volume; check individual\nsolids because aggregate volumes can cancel. Open shells can be valid when\nsurfaces are intended. No free edges establishes closure, not full manifold\nvalidity. Run self-intersection checks explicitly when required and report\nany checks you did not complete.\n\n## Mass properties\n\n```python\nfrom cadgen.geometry import mass_properties\n\nproperties = mass_properties([\n    (aluminum_body, 2.70e-6),  # kg/mm³; use the material density for the task\n    (steel_insert, 7.85e-6),\n])\nprint(properties.volume, properties.mass, properties.center_of_mass)\nprint(properties.inertia)\n```\n\n`mass_properties(Iterable[tuple[Solid, float]]) -> MassProperties` integrates\nuniform density per solid and sums the supplied bodies, including overlaps.\nDensities must be finite and positive. It does not infer materials or fuse\nbodies. The 3×3 inertia tuple is about the combined center of mass in the input\ncoordinate axes. With mm and kg/mm³, outputs are mm³, kg, mm and kg·mm².\n\nUse snapshots for visual review per `snapshot-review.md`. Turn visual concerns\ninto explicit geometric checks before claiming they have been resolved.\n","skills/cad/references/kinematics.md":"# CAD kinematics and animation\n\nRead this file when the user asks to articulate, pose, or animate a STEP\nmodel, or when designing or reviewing mates, couplings, pose presets, posed\nexports, or animation clips.\n\nThere are THREE systems with different lifecycles, deliberately independent:\n\n- **Geometry** is the module's constants and the factory the parameterless\n  model calls with them (`WIDTH = 10.0` … `return _bracket(WIDTH)`). Changing\n  one re-runs Python and rebuilds the outputs. They are not live in the viewer.\n- **Kinematics** is typed mates declared as PURE DATA via `kinematics=` on\n  the export decorators. It drives the viewer's pose sliders — no rebuild, no\n  Python at render time — and never moves the geometry a model writes. It\n  lives in the model's sidecar (`<name>.step.json`, written beside the\n  artifact), alongside any intrinsic material appearance.\n- **Animation** is choreography declared as a self-contained JavaScript ES\n  module string via `animation=` on `@step`. The build embeds it in\n  `<name>.step.json`; the viewer, snapshot door, and animated GLB export read\n  that document-bound copy. Animated GLB export hashes the source with the clip\n  request, so an animation edit invalidates that export. Animation targets\n  occurrences directly and knows nothing about mates. Like materials and\n  kinematics, it never changes STEP geometry or bytes.\n\n## Kinematics: typed mates\n\nKinematics is one sidecar section, independent of intrinsic material appearance.\nIt never moves saved geometry: the declaration describes how the written tree\narticulates, and the viewer poses it at render time. A STEP model with no\nkinematics, intrinsic material finishes or animation has no sidecar.\n\nOne `kinematics=` dict, closed keys `mates` / `couplings` / `poses`, on any of\n`@step`/`@stl`/`@glb`/`@threemf`. Each decorator's declaration stands alone\n(share a module-level dict; there is no cross-decorator inheritance).\n\n```python\nimport cadgen\nfrom cadgen import step\nfrom cadgen import build123d as bd\n\nKINEMATICS = {\n    \"mates\": [\n        cadgen.revolute(\"elbow\", parent=\"#upper_arm\", child=\"#forearm\",\n                        origin=(0, 0, 0), direction=(0, 0, 1), limits=(0, 150)),\n        cadgen.revolute(\"wrist\", parent=\"#forearm\", child=\"#hand\",\n                        origin=(80, 0, 0), direction=(0, 0, 1), limits=(0, 90)),\n    ],\n    \"couplings\": [cadgen.couple(\"curl\", {\"elbow\": 1, \"wrist\": 0.5}, limits=(0, 90))],\n    \"poses\": {\"straight\": {\"curl\": 0}, \"bent\": {\"curl\": 60}},\n}\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS)\ndef arm():\n    upper_arm = bd.Pos(-40, 0, 0) * bd.Box(80, 10, 10)\n    forearm = bd.Pos(40, 0, 0) * bd.Box(80, 10, 10)\n    hand = bd.Pos(90, 0, 0) * bd.Box(20, 10, 10)\n    upper_arm.label, forearm.label, hand.label = \"upper_arm\", \"forearm\", \"hand\"\n    return bd.Compound(children=[upper_arm, forearm, hand], label=\"arm\")\n\n\nif __name__ == \"__main__\":\n    arm()\n```\n\n- **Mate kinds**: `revolute` (degrees about an axis), `slider` (model units\n  along it), `cylindrical` (sub-DOFs `<name>.turn` and `<name>.travel` about\n  one axis), `fastened` (0-DOF rigid attachment — needed exactly when\n  occurrences are SIBLINGS in the instance tree, like a pin that must orbit\n  with its carrier; instance-tree children ride for free).\n- **`limits=(lo, hi)`** is required on every mate that moves, and both ends\n  must be finite: Position sliders and exports read the range. A joint that\n  turns freely takes a full range such as `(-180, 180)`; `math.inf` fails the build.\n- **`parent`/`child`** are occurrence refs: `#`-prefixed labels (canonical —\n  label parts with `cadgen.label_shape`) or occurrence ids. They must resolve\n  at build or the build fails; `read_scene(path).leaves()` lists saved geometry occurrences.\n  A label resolves **into linked children**: a part labelled inside a\n  sub-assembly you call (`#shoulder_yaw_servo` living in `base_link()`'s\n  tree) resolves to its occurrence under the link (`o1.1.1`), so an assembly\n  can mate parts of a sub-assembly it links without owning their geometry.\n  A ref may name a SUBASSEMBLY as well as a part — a labelled group `Compound`\n  is an occurrence in the instance tree, and mating it carries every part\n  beneath it. That is how a rocker-bogie chain is three mates instead of three\n  hundred. `scene.roots` and `occurrence.children` expose those groups. Targets may NEST — a part inside a mated group may carry its\n  own mate to a sibling (a servo's output horn bolted to the jaw it drives):\n  the DEEPEST mate naming a part owns it and moves it exactly once, and the\n  enclosing group carries only what no deeper mate claimed.\n- **`axis`** is a selector ref (`axis=\"#forearm.f2\"` — a cylindrical\n  face or circular edge yields its axis, a planar face its center+normal) or\n  literals (`origin=(x, y, z), direction=(x, y, z)`). Refs resolve ONCE at\n  build into world numbers; the viewer does arithmetic, never topology.\n- **ZERO IS THE ARTIFACT AS WRITTEN.** Every DOF's rest value is 0 — the\n  placement the author built. There is no `default=`; a presentation pose is a\n  preset. A model that must be WRITTEN at another configuration is authored\n  at that configuration (or is another model): no decorator argument moves\n  geometry.\n- **`couple(name, {dof: ratio})`** declares a virtual DOF gearing real ones\n  linearly and ADDITIVELY (above, setting `curl=x` adds `x` degrees to `elbow`\n  and `0.5*x` to `wrist`).\n  Exact gear trains are ratio arithmetic, not code.\n  A geared member BACK-DRIVES in the viewer: when exactly one coupling gears a\n  DOF with a nonzero ratio, its Position slider reads the effective value\n  (own + ratio x coupling), is labelled \"driven by <coupling>\", and dragging it\n  moves the COUPLING — `coupling = (target - own)/ratio`, clamped to the\n  coupling's limits — so sliding one gear turns the whole train. A member's own\n  value (from a preset or `--kinematics`) is never overwritten, and a DOF geared\n  by two couplings stays independent: that inverse is underdetermined, so the\n  viewer refuses it rather than guessing a split.\n- A declaration needs at least one mate (or coupling): a pose is a set of joint\n  values and a joint is what a mate declares, so `poses` alone declare nothing\n  and are refused. A part with no joints declares no `kinematics=`.\n- **`poses`** are named `{dof: value}` presets — all that remains of \"pose\"\n  as a concept.\n- The mate graph is a TREE: one parent mate per occurrence, no cycles.\n  Closed-loop linkages (four-bars) are out of scope by design — they need a\n  solver; the viewer evaluates pure forward kinematics from the sidecar's\n  numbers at render time.\n\n## Annotating a STEP you did not generate\n\nA document with no model script gets its kinematics from\n`cadgen step build IN OUT`, whose `--kinematics` takes the whole SPACE — the\nsame `{mates, couplings, poses}` vocabulary, as inline JSON or a `.json`\npath. `--materials` accepts the named material declaration as inline JSON or\na `.json` path, and `--animation` accepts a self-contained JavaScript module\nfile or source string that exports only `clips` (see below). The input is read\nwith OCCT and re-emitted by the canonical writer, so OUT's bytes are\ndeterministic whichever kernel wrote IN:\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\n**Wrapper script or `step build`?** A model that will keep changing belongs in a\nscript — a thin `@step` function that imports the foreign STEP and re-exports\nit, so the kinematics live beside the geometry decisions and every edit is one\n`python model.py`. Reach for `step build` when the geometry is fixed and not\nyours: a one-shot annotation or canonicalization of a vendor file. Re-running it\nis a no-op, editing only these annotations refreshes the sidecar without\nre-emitting a byte, and vendor metadata (PMI, GD&T) does not survive the trip.\n\n## Animation: the embedded module\n\nA STEP document may carry one self-contained JavaScript animation module in\nits unified sidecar. Author the module as a Python string and pass it to\n`@step(animation=...)`. It exports `clips` and nothing else; leave helpers and\nconstants unexported. The renderer refuses a module with any other export, and\nevery clip with it, so the build refuses it first, in the renderer's words:\n`arm.py::arm animation: unknown export ease — the renderer understands: clips`.\nThe module has no imports. For the arm above, add this constant and update its\ndecorator, keeping the same model body:\n\n```python\nANIMATION = r\"\"\"\nexport const clips = {\n  demo: {\n    label: \"Demo\",\n    duration: 8,          // seconds\n    loop: true,           // default\n    update(t, m) {        // called every frame; t in seconds\n      const angle = 60 * (1 - Math.cos(2 * Math.PI * t / 8));\n      m.get(\"forearm\").rotate([0, 0, 1], angle, [0, 0, 0]);\n      m.get(\"hand\").rotate([0, 0, 1], angle, [0, 0, 0]);\n    },\n  },\n};\n\"\"\"\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS, animation=ANIMATION)\ndef arm(): ...\n```\n\n- `m.get(target)` takes a LABEL (canonical) or occurrence-id refs\n  (`\"#o1.3.1\"`, comma lists; each id covers its whole subtree). Unknown\n  targets THROW — a typo never silently animates nothing. Labels here match\n  RENDERED PARTS only: to animate a whole group, name its occurrence id.\n- Handles support `.rotate(axis, degrees, origin=[0,0,0])`, `.translate(vec)`,\n  `.opacity(0..1)` and `.visible(bool)`. Successive transforms premultiply:\n  spin about a part's center first, then orbit the origin, and the spin rides\n  the orbit. For flexible swept bodies, see\n  [tube deformation and morph export](animation-deformation.md).\n- Every frame starts from rest and `update(t)` rebuilds the state — a pure\n  function of t, so scrub/loop/seek are free. No wall-clock, no state.\n- Animation targets occurrences independently of mates. Rerun the model after\n  editing its animation to refresh the sidecar. Literal annotation edits may\n  reuse cached geometry; computed or imported annotations can require a rebuild.\n  See [annotation caching](step-generation.md#annotation-caching).\n- The model build validates the declaration, exports included, and embeds it.\n  A model without `animation=` is simply a model without animation.\n- Targets are checked at LOAD, against the compiled tree: every clip's\n  `update(0, m)` runs once when the module loads, and a label or occurrence\n  id no part carries is reported in the viewer's Issues (`Animation\n  unavailable`, in the file's panel) and in\n  `snapshot --animation`'s error — not at the first frame that reaches it.\n- Mesh-only models (no `.step`) have no document sidecar; animation is a\n  STEP-document concern.\n\n## Reviewing motion\n\nReview motion interactively in the viewer or render a clip video as described\nbelow. For a still of the arm example's configuration, render at DOF values:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics '{\"curl\": 60}'\n```\n\n`--kinematics` is named for the `kinematics=` block it drives, and takes\neither spelling: `{dof: value}` JSON, or the NAME of a pose the model\ndeclares under `poses`. A name is checked against the declaration, so a typo\nfails with the poses this model actually has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics bent\n```\n\nFor still evidence of a CLIP, freeze one frame: `--animation` names a clip\nthe document sidecar's embedded animation declares and `--time` the\nmoment in seconds (default 0). The frame\nis composed exactly as the viewer composes it: `--kinematics` sets the base\npose, and the clip's `update(t, m)` is evaluated at that time on top of it.\nA clip name the model does not declare fails with the clips it has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo_t2.png --animation demo --time 2.0\ncadgen step snapshot STEP/arm.step tmp/demo_bent.png --kinematics bent --animation demo --time 2.0\n```\n\nIn a JSON job the request is one field, `\"animation\": {\"clip\": \"demo\",\n\"time\": 2.0}`, beside `\"kinematics\"`; the Python door takes the same object\n(`step.snapshot(..., animation={\"clip\": \"demo\", \"time\": 2.0})`) or the clip\nname with `time=`.\n\n### Rendering the whole clip\n\n`--video` renders the SPAN instead of a moment, into the `.mp4` or `.gif` the\nOUT names. Everything else is unchanged — same unified display settings, camera\nand size profile as a still, and the same `--kinematics` base pose underneath:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo.mp4 --animation demo --video '{\"fps\": 30}'\ncadgen step snapshot STEP/arm.step tmp/demo.gif --animation demo \\\n  --video '{\"fps\": 12, \"seconds\": 3, \"start\": 1.5, \"quality\": \"draft\"}'\n```\n\nThe request's keys, all optional:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `fps` | `30` | frames per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to render |\n| `start` | `0` | seconds into the clip where the video begins; must be inside it |\n| `quality` | `review` | `draft`, `review`, or `high` |\n| `loop` | `true` | GIF only — an `.mp4` carries no loop count, so `\"loop\": false` on one is refused |\n\nThe span is measured against the clip rather than trusted, because the clip\nevaluator ANSWERS a time past the end instead of refusing it: a clip that does\nnot loop holds its final pose, so an overrunning span buys frames that are all\none still image, and a looping clip wraps, so it renders a different span than\nthe one asked for. A `start` at or past the end is refused; an explicit\n`seconds` that overruns a clip which does not loop renders and warns. Hence the\ndefault: a looping clip gets one whole cycle from wherever it starts, and a clip\nthat stops gets the part of it that is left. `fps * seconds` is capped at 7200\nframes — every frame is a full-size PNG on disk before ffmpeg runs.\n\nWidth and height come from `--width`/`--height`/`--size-profile` like any\nother render; there is no size key here. `--video` needs `--animation` (it\nrenders a clip) and refuses `--time` (that freezes one frame instead), and it\nneeds **ffmpeg** on `PATH` — or `CADGEN_FFMPEG` pointing at one. A missing\nencoder is reported before the first frame is drawn, never after minutes of\nrendering. A GIF stores its frame delay in hundredths of a second, so a player\nshows the nearest rate to the one asked for; `.mp4` is exact.\n\nThe camera is fitted ONCE, to everything the clip covers, so the model moves\nand the frame does not. That is why a long clip is worth trimming with\n`start`/`seconds`: framing the whole of a clip that travels a long way leaves\nthe interesting part small.\n\nIn a JSON job the request is a `\"video\"` object beside `\"animation\"`, and a\nvideo job carries exactly one output:\n\n```json\n{\n  \"input\": \"STEP/arm.step\",\n  \"animation\": { \"clip\": \"demo\" },\n  \"video\": { \"fps\": 24, \"quality\": \"high\" },\n  \"outputs\": [{ \"path\": \"tmp/demo.mp4\", \"camera\": \"iso\" }]\n}\n```\n\nThe result names the file and what it covers — `saved video: tmp/demo.mp4\n(120 frames, 30 fps, 4s)` — so a wrong clip or a wrong span shows up without\nopening it. Still snapshots remain the evidence for a POSE; a video is for\nmotion a still cannot show.\n\n### Exporting the clip INSIDE a GLB\n\nA video is pixels. `cadgen glb build --animation` writes the motion itself: the\nclip is sampled into glTF node animation, so whatever opens the `.glb` — Blender,\na three.js viewer, a browser's model preview — plays it. There is no camera, no\nquality and no encoder here, and `fps` means something different: the SAMPLING\nrate of the baked keyframes, not a playback rate.\n\n```bash\ncadgen glb build STEP/arm.step GLB/animated/arm.glb --animation demo\ncadgen glb build STEP/arm.step GLB/animated/arm.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\nThe request is a clip name, or an object whose keys are all optional but `clip`:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `clip` | — | the clip to bake; required |\n| `fps` | `30` | keyframe samples per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to bake |\n| `start` | `0` | seconds into the clip where the span begins; must be inside it |\n| `drop` | `[]` | effects to bake STATIC instead of refusing: `opacity`, `visible` |\n| `deform` | `refuse` | what to do with `.deformTube()`: `refuse`, `morph`, `rest` |\n| `deformTolerance` | `1.0` | `morph` only — millimetres the baked tubes may sit from the clip's own deformation, `0.01`..`10` |\n\nThe span is resolved exactly as `--video`'s is — a looping clip defaults to one\nwhole cycle, a clip that stops gets what is left of it, and `fps * seconds` is\ncapped at 7200 samples. The exported animation is re-based to zero, so `start`\npicks where in the CLIP the span begins and the file still opens at t = 0.\n\n**What glTF carries, and what it will not:**\n\n| clip effect | in the GLB |\n| --- | --- |\n| `.rotate()` | sampled rotation channel on that occurrence's node |\n| `.translate()` | sampled translation channel on the same node |\n| `.rotate()` about a pivot | sampled rotation and translation channels |\n| `.opacity()` | **refused.** glTF has no animated opacity. `\"drop\": [\"opacity\"]` bakes the value at `start` as a material alpha, and warns |\n| `.visible()` | **refused.** Same reason. `\"drop\": [\"visible\"]` omits whatever is hidden at `start`, and warns — an occurrence dropped this way loses its motion too, because a node that is not in the file cannot be animated |\n| `.deformTube()` | **refused by default.** Per-vertex motion, not a node transform. `\"deform\": \"morph\"` bakes it as glTF morph targets; see [deformation](animation-deformation.md); `\"deform\": \"rest\"` ships those tubes at rest shape and warns |\n\nNothing is dropped quietly: an effect the file cannot carry stops the export and\nnames the occurrences, so a hand whose tendons froze on the way out is a refusal\nrather than a finished-looking file. Render the clip with\n`cadgen step snapshot --animation <clip> --video` when the motion is one of\nthose — `--video` needs the clip named too, so both flags go together.\n\nFor `.deformTube()` authoring, morph fitting, memory limits and braid export\nlimitations, read [tube deformation and morph export](animation-deformation.md).\n\nThe CAD Viewer plays a GLB's embedded rigid, skinned and morph animation from its\nfile sidebar's Animation section, in the tools view and Preview alike. These are baked clips: the STEP\nsidecar module's procedural controls are not available in the exported GLB.\n\nAn animated export writes ONE node per occurrence instead of the flat,\ncolour-grouped mesh a static one writes, so the file is bigger and its parts are\nindividually addressable. Only occurrences the clip actually MOVES get channels;\nan occurrence held at a constant offset carries it on its node and nothing else.\nFreshness folds the clip request and the sidecar animation source into the export's key, so\nediting the choreography re-exports even though the STEP has not changed.\n\n`--animation` is GLB's alone, and the CLI is generated from each door's\nsignature, so `cadgen stl build` and `cadgen 3mf build` have no such flag at all\n— they exit 2 with `unrecognized arguments: --animation`. Neither format has\nanywhere to put a clip; export it as `.glb`, or render it with\n`cadgen step snapshot --animation <clip> --video`.\n\nAnimated GLB requires an explicit OUT. A static export without OUT writes one\nsibling `.glb` beside the STEP; it does not read model output declarations.\nChoose a separate destination for the animated file when retaining both.\n\nChoose pivots and axes for the intended motion. For hinged motion, use the\nphysical hinge axis. Verify the relevant dimensions, alignments and clearances\nwith geometry checks.\n","skills/cad/references/migrations.md":"# Migrations\n\nRead this file when source, command syntax or sidecars target a different\ncadgen version. First run `cadgen doctor <skill-dir>` to check the installed\nversion against the skill's pin. Follow the specific error and current command\nhelp; a modeling failure alone does not establish version skew.\n\ncadgen uses hard interface cutovers. A retired interface may fail with a\nteaching error naming its replacement; it is not a compatibility alias.\nThe retired inspect CLI is replaced by Python checks using `read_scene` and\nnative geometry; see [inspection](inspection-and-validation.md).\n\n## A migration message is a task\n\nWhen a build, snapshot or validation reports that a model needs\nmigrating (a sidecar refused for its schema version, a leftover companion file\nthe build warns about, a retired decorator argument or command named by a\nteaching error), do the migration then, as part of the work in hand. Do not\nroute around it, silence it, or leave it for later: the CAD Viewer does not\nflag a stale model, so an unmigrated one simply loses its kinematics,\nmaterials and animation and looks like a plain part.\n\n- A refused sidecar (`unsupported sidecar schema N (expected M)`) is fixed by\n  running the model's script again (`python <model>.py`), which writes a current\n  sidecar beside the STEP. For an imported STEP that has no script, re-annotate\n  it with `cadgen step build`. Never edit `schemaVersion` by hand.\n- A leftover companion render module beside the STEP (a `.js` file named after\n  the document; the build warns it \"is a retired render module\") is read by\n  nothing, so its clips are missing from the model. Move them into\n  `@step(animation=...)` ([kinematics](kinematics.md)), delete the file and\n  rebuild.\n- After migrating, confirm it took: the sidecar declares the current schema and\n  the document's hash, the build printed no migration warning, and the model\n  articulates (a `cadgen step snapshot --kinematics …` pose differs from rest,\n  or the Viewer shows a Position section in the file's panel, the Position\n  tool and, for a model with routines, its routines playing in preview).\n- If the script itself no longer runs on the installed cadgen, migrate the\n  script using the sections below, then rebuild.\n\n## When to suspect skew\n\n- **A model script runs, exits 0, and writes nothing.** An older source carries\n  no decorated function and no entry point of its own, so Python defines a\n  function and exits. Nothing looks for an entry point by name.\n- **A command or flag you are sure of comes back unknown**, and the help lists\n  an unfamiliar set. Building a model is running its script; there is no\n  generation verb. Consult current help and any replacement named by the error.\n- **A sidecar is refused for its schema version.** Sidecars are never upgraded\n  in place and never partially read, because a wrong-shaped one would cost a\n  model its kinematics silently.\n- **A model that used to articulate renders inert**, presenting as a plain\n  document with no pose and no animation. Nothing is discovered by convention:\n  kinematics and animation are `kinematics=` and `animation=` on the model's\n  decorator, and the build puts both in the document's sidecar. A companion\n  JavaScript file beside the document is no longer read. A generated model's\n  build warns about the leftover file and names the replacement decorator;\n  the warning does not stop the build.\n- **Meshes come out visibly coarser or finer, with no error.** Mesh tolerance\n  kept its name and changed meaning — chord tolerance is a fraction of the\n  component's bounding diagonal, not an absolute length — so a value carried\n  across from an older project is wrong in proportion to the part's own size.\n  A carried-over value above `0.05` is refused outright with the conversion\n  (X mm on a part whose diagonal is D mm is X/D); a smaller one is accepted, so\n  check it against the part's size.\n\nA migrated source may still have incompatible saved outputs. Rebuild or\nre-annotate the affected document as the error directs; preserve imported\nsources and do not delete unrelated artifacts to diagnose a version mismatch.\n\n## Migration guides\n\n- **cadgen 0.4 → 0.5** — generator functions became decorated model scripts, the\n  generation CLI was removed, sidecars and provenance moved, snapshot job JSON\n  was re-keyed, and mesh tolerance became relative.\n  https://github.com/earthtojake/text-to-cad/blob/main/docs/migrations/migrating-0.4-to-0.5.md\n","skills/cad/references/positioning.md":"# Assembly positioning and mating\n\nRead this file when placing assembly parts, defining mating datums or checking\nalignment. Author placements in the model source, then check the saved geometry.\nChoose explicit transforms or native build123d joints according to which makes\nthe intended relationship easiest to express and maintain. Neither requires a\nparticular assembly size or complexity.\n\n## Transforms and local frames\n\nUse functional datums and explicit dimensions for placements. A part's origin\nmight be its mounting interface, symmetry center or rotational axis; use the\nconvention that fits the design. Record it when another part depends on it.\n\n```python\n# Inside an assembly model:\nleft = bd.Pos(-pitch / 2, 0, height) * spacer()\nleft.label = \"spacer_left\"\nright = bd.Pos(pitch / 2, 0, height) * spacer()\nright.label = \"spacer_right\"\nassembly = bd.Compound(children=[left, right], label=\"spacer_pair\")\n```\n\nPlace child models with `.moved()` or `Pos/Rot/Location * child` to preserve\nshared geometry. `.located()` replaces the existing placement and copies the\ngeometry; it loses the cached child link. Labels identify roles and repeated\noccurrences, such as `m3_screw:front_left`. A functional group can be a nested\nlabeled `Compound` when the design benefits from that hierarchy.\n\nMirroring changes geometry, so an inline mirrored child belongs to the parent\nrather than linking to the original child's tree. It remains cached with that\nparent. A separate mirrored model is useful for independent outputs or reuse,\nnot a requirement; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\n\n## Native joints\n\nJoints can express a relationship between reusable datums without hand-solving\nits placement. They perform source-level placement; they are not persistent\nSTEP constraints or a replacement for the separate [kinematics](kinematics.md)\ninterface. Call `connect_to()` on the fixed joint with the moving joint as its\nargument.\n\nFor example, `src/enclosure.py` places a lid above a base with a specified gap:\n\n```python\nfrom cadgen import build123d as bd, step\n\nBASE_HEIGHT = 30.0\nLID_THICKNESS = 3.0\nGASKET_GAP = 0.5\n\n\n@step(out=\"../STEP/enclosure.step\")\ndef enclosure():\n    base = bd.Box(80, 50, BASE_HEIGHT)\n    lid = bd.Box(80, 50, LID_THICKNESS)\n    base.label, lid.label = \"base\", \"lid\"\n    bd.RigidJoint(\n        \"lid_target\", to_part=base,\n        joint_location=bd.Location((0, 0, BASE_HEIGHT / 2 + GASKET_GAP)),\n    )\n    bd.RigidJoint(\n        \"underside\", to_part=lid,\n        joint_location=bd.Location((0, 0, -LID_THICKNESS / 2)),\n    )\n    base.joints[\"lid_target\"].connect_to(lid.joints[\"underside\"])\n    return bd.Compound(children=[base, lid], label=\"enclosure\")\n\n\nif __name__ == \"__main__\":\n    enclosure()\n```\n\nNative joint options include `RigidJoint` for fixed placement, `RevoluteJoint`\nfor rotation, `LinearJoint` for translation, `CylindricalJoint` for combined\nrotation/translation and `BallJoint` for spherical orientation. Use `Location`\nfor rigid/ball joint frames and `Axis` for revolute/linear/cylindrical joints.\n\nCreating joints reads the child's placement and may materialize it earlier\nthan a deferred transform. Cached child models return geometry, labels,\nappearance and placements; do not rely on Python joint objects surviving a\nchild's cache round trip. Define the joints needed by the parent in its source.\n\n## Child models and imported components\n\nCall a project model to compose it. Use `cadgen.read_step` for a vendor document\nor an explicitly decoupled export; like every file a build reads, it is an input.\nSee the [model contract](step-generation.md) for dependency tracking.\n\nFor imported geometry, inspect its existing origin, orientation and functional\nfeatures with `read_scene` before choosing datums. Express measured offsets\nor joint frames in the source, then validate the placed result. Anchor model\ninput paths on `__file__` when they must work from any working directory.\n\n```bash\npython path/to/assembly.py\npython tmp/check_assembly.py\n```\n\n## Alignment and measurement checks\n\nSelect the actual mating features from `read_scene`, then express the intended\nrelationship in Python. There is no generic alignment mode that guesses which\npoints or axes the design means. See `inspection-and-validation.md` for the\nreader and native measurement interfaces.\n\nFor example, for two planar mating faces (with their refs already identified):\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"STEP/assembly.step\")\n(moving_face, fixed_face) = scene.resolve(\"#moving.f1,fixed.f2\")\nmoving, fixed = moving_face.shape(), fixed_face.shape()\nn = fixed.normal_at().normalized()\nm = moving.normal_at().normalized()\nsigned_gap_mm = (moving.center() - fixed.center()).dot(n)\nparallel_error_deg = min(n.get_angle(m), n.get_angle(-m))\nprint(signed_gap_mm, parallel_error_deg)\nassert abs(signed_gap_mm) < 0.01\nassert parallel_error_deg < 0.1\n```\n\nThese thresholds are illustrative; use the task's tolerances. The plane gap\nand parallelism test does not establish lateral alignment or overlapping face\nfootprints. Check those when required. For opposed normals, assert the signed\norientation explicitly instead of accepting either parallel direction.\n\nFor a screw-pattern dimension, compare the analytic centers of the selected\ncircular edges. For clear space between bodies use `closest_points`.\n`shape.bounding_box().size` measures the world-aligned envelope. Use explicit\nvectors and datums for orientation, signed offsets and center spacing.\nUpdate source placements, regenerate, and rerun the relevant checks.\n\nCorrect failed positioning in the model source, then regenerate and rerun the\nrelevant checks. Report measured relationships and any intended alignment left\nunchecked; no positioning report is needed when positioning is irrelevant.\n","skills/cad/references/project-layout.md":"# CAD project structure\n\nFollow an existing project's layout. For a new multi-model project, use the\nstructure below; a one-off part can use a flat folder. In a larger workspace,\nput CAD work in its existing `models/`, `cad/` or `hardware/` area. In a bare CAD\nworkspace, use the root. cadgen does not infer paths from folder names: each\nmodel declares its outputs.\n\n## Source and output mapping\n\n**One model entrypoint per `.py` file, with matching output stems.** For\nexample, `src/bracket.py` contains one parameterless decorated model and declares\n`STEP/bracket.step`, `STL/bracket.stl`, `GLB/bracket.glb` or `3MF/bracket.3mf`, as\nneeded. Stacking output decorators still declares one model. Helpers and package\n`__init__.py` files are not model entrypoints.\n\n```\n<project>/\n  src/\n    README.md             # model catalog\n    plate.py              # one model per entrypoint\n    plate_drawing.py       # separate drawing model\n    assembly.py           # root assembly\n    chassis/              # optional grouping as the project grows\n      __init__.py\n      frame.py\n      strut.py\n    purchased/            # optional read_step wrapper models for vendor parts\n      servo.py\n    lib/                  # shared helpers and parameterized factories\n      __init__.py\n      holes.py\n  STEP/                   # raw artifacts and generated sidecars\n    plate.step\n    chassis/\n      frame.step\n      strut.step\n    imported/             # external source files\n  DXF/  STL/  GLB/  3MF/  # other requested output formats\n  checks/                 # reusable checks, or the existing test directory\n  tmp/                    # ignored exploratory scripts, snapshots and debug output\n```\n\nKeep code and notes outside the format folders. Mirror nested source paths in\noutput folders so repeated names remain unambiguous:\n`src/chassis/frame.py` → `STEP/chassis/frame.step`. For several mesh densities\nof the same model, retain the stem in separate variant subfolders, such as\n`STL/draft/bracket.stl` and `STL/print/bracket.stl`.\n\nDecorator `out=` paths are relative to the script. For `src/plate.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\nRun `python src/plate.py` from the project root. An unchanged model is a no-op.\nThe CAD Viewer catalogs the saved artifacts; the source catalog identifies what\nto build before those files exist.\n\nPut exploratory inspection scripts in ignored `tmp/` or system `/tmp`, and\nretained checks in `checks/` or the project's test directory. See\n[inspection](inspection-and-validation.md) for examples.\n\n## Imports and shared code\n\nUse ordinary Python imports. Flat scripts can import sibling models and\n`lib/` directly. For nested scripts, declare the import root with `PYTHONPATH`;\ncadgen adds no paths of its own. From the project root:\n\n```bash\nPYTHONPATH=src python src/chassis/frame.py\n```\n\nPython packages and relative imports are supported. With\n`src/chassis/__init__.py`, run the model as a module when it uses relative imports:\n\n```bash\nPYTHONPATH=src python -m chassis.frame\n```\n\nDirect execution puts the script's directory first on the import path. Avoid a\nfilename that shadows a package it imports: `frame/frame.py` can hide the `frame`\npackage when run by path. Use a distinct entrypoint name or module execution.\n\nKeep shared factories, constants and helpers in `src/lib/` or an existing Python\npackage. Subsystems may import shared models or each other where the dependency\nmakes sense; avoid circular imports. Alias a module when importing a same-named\nmodel function would otherwise shadow it.\n\nDependencies are tracked as **models by result, constants by value, functions by reach**:\n\n```python\nfrom lib import fasteners  # helper module: tracked by what the model can run in it\nfrom plate import WIDTH    # literal from a model module: tracked by value\nfrom plate import plate    # model: calling it pins its result\n```\n\n`cadgen store why src/<model>.py` explains freshness. Importing a model never\nbuilds it; calling it inside an assembly does. See the\n[model contract](step-generation.md#models-inside-a-package) for package execution\nand dependency details.\n\n## Assemblies and model boundaries\n\nA parent calls its child models inside its body. The runtime builds stale children\nor reuses their cached results and links them into the parent: **running the root is the\nwhole build**. Run `python src/assembly.py`; rebuilding a child alone\ndoes NOT rebuild the assemblies that use it, so rerun the parent to incorporate\nthe change.\n\nGive a subassembly its own model when it needs independent outputs, reuse or a\nseparate cache boundary. Local construction can remain inline or in an ordinary\nhelper. A helper executes within its caller; a child model has its own record\nand reusable result. Separate folders are useful as the project grows, but a\nsubassembly does not require one. Group related files for navigation without\nforcing the directory tree to reproduce every assembly level.\n\nMirror inline for geometry local to a model; use separate left/right models\nwhen independent outputs or reuse justify them. See\n[mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\nA print-only part can declare `@stl`, `@glb` or `@threemf` without `@step` and\nstill compose into an assembly.\n\nUse factory arguments for configurations, with one entrypoint per independently\nexported model. Geometry must not depend on untracked environment variables,\nworking-directory state, time or random values; the cache cannot detect those\nchanges. The files a model reads are tracked on their own (the\n[model contract](step-generation.md)).\n\n## Naming\n\n- Use an importable Python identifier for the model script's stem, and retain\n  that stem in its declared outputs. Put an external part number in a label or\n  catalog entry if it cannot serve as the shared filename stem.\n- A drawing is a separate model: `plate_drawing.py` → `DXF/plate_drawing.dxf`.\n- Avoid filenames distinguished only by case; many filesystems ignore case.\n- Preserve upstream filenames for imported sources under the format's\n  `imported/` folder. They are inputs, not generated model outputs, and do not\n  need a matching script.\n\n## Renaming or retiring outputs\n\nChanging `out=` or deleting a model does not remove its previous artifacts.\nBefore a rename, record the old output paths, any `.step.json` sidecar and\ndeclared meshes. Rename the entrypoint and its matching outputs, rebuild and\nverify the new files, then remove only the obsolete generated files by exact\npath. Preserve imported sources and unrelated artifacts.\n\n## Building many models\n\nRunning a root builds its dependencies. For independent outputs, run the\ncatalog's entrypoints; independent roots can run concurrently, subject to the\nruntime's CPU and memory admission. Avoid duplicate simultaneous exports of the\nsame document. Batch snapshot views in one JSON job when they share scene settings.\n\nWhen several agents work on an assembly, independent subsystem entrypoints let\nthem build and verify their work before the root incorporates it.\n\n## Model catalog\n\nFor a multi-model project, keep `src/README.md` short and update it when the\nentrypoints or outputs change:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Mounting plate |\n| plate_drawing.py | DXF/plate_drawing.dxf | Plate flat pattern |\n| assembly.py | STEP/assembly.step | Plate with standoffs |\n\nBuild: `python src/assembly.py` from the project root builds the assembly and\nits dependencies. Run independent entrypoints separately. Unchanged models are current.\nImported source: STEP/imported/servo.step.\n```\n\n## Version control\n\nFollow the repository's existing ignore, artifact and Git LFS policies, and the\nuser's commit instructions. For a new project, a useful default is to retain\nauthored code, reusable checks and irreplaceable inputs, while ignoring\nreproducible outputs and scratch files. Keep pinned fixtures or deliverables\nwhen the project requires them; kernel upgrades can change bytes without\nchanging geometry.\n\nExample ignore patterns for that default:\n\n```gitignore\n/STEP/*\n!/STEP/imported/\n/DXF/*\n!/DXF/imported/\n/STL/*\n!/STL/imported/\n/GLB/*\n!/GLB/imported/\n/3MF/*\n!/3MF/imported/\n/tmp/\n__pycache__/\n```\n\nThe `*` forms allow Git to descend into the format folders and honor the\n`imported/` exceptions. Git LFS is optional unless the repository requires it;\nuse it for large binary inputs when appropriate. See the\n[starter](project-template.md#project-bookkeeping) for an LFS example.\n\n## Scaffolding a new project\n\nChoose a [minimal starter](project-template.md): a single part or a small\nassembly. Add other formats, helpers, groups and imported files only as needed.\nBuild the selected entrypoint, check its geometry and review its snapshot.\n","skills/cad/references/project-template.md":"# Minimal project starters\n\nChoose the starter that fits the task. Create only the files it needs; rename\nparts and change dimensions for the design. Keep an existing project's layout.\nFor a new project, use the location and conventions in [project layout](project-layout.md).\nCommands below run from the CAD project root.\n\n## One part\n\nCreate `src/plate.py`:\n\n```python\n# src/plate.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 60.0\nDEPTH = 40.0\nTHICKNESS = 4.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    body = bd.Box(WIDTH, DEPTH, THICKNESS)\n    body.label = \"plate\"\n    return body\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\n```bash\npython src/plate.py\ncadgen step snapshot STEP/plate.step tmp/plate.png\n```\n\nReview the PNG and check the dimensions needed by the design. A second model\nrun should report `current`. A one-off part can instead use a flat folder and\n`@step` with its default sibling output.\n\n## An assembly\n\nStart with the plate above and add `src/standoff.py`:\n\n```python\n# src/standoff.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nHEIGHT = 12.0\nOUTER_D = 8.0\nBORE_D = 3.4\n\n\n@step(out=\"../STEP/standoff.step\")\ndef standoff():\n    return bd.Cylinder(OUTER_D / 2, HEIGHT) - bd.Cylinder(BORE_D / 2, HEIGHT)\n\n\nif __name__ == \"__main__\":\n    standoff()\n```\n\nThen `src/assembly.py` places two instances on the top of the centered plate:\n\n```python\n# src/assembly.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom plate import THICKNESS, plate\nfrom standoff import HEIGHT, standoff\n\nPITCH = 30.0\n\n\n@step(out=\"../STEP/assembly.step\")\ndef assembly():\n    base = plate()\n    base.label = \"plate\"\n    post = standoff()\n    z = (THICKNESS + HEIGHT) / 2\n    left = bd.Pos(-PITCH / 2, 0, z) * post\n    left.label = \"standoff_left\"\n    right = bd.Pos(PITCH / 2, 0, z) * post\n    right.label = \"standoff_right\"\n    return bd.Compound(children=[base, left, right], label=\"assembly\")\n\n\nif __name__ == \"__main__\":\n    assembly()\n```\n\n```bash\npython src/assembly.py\ncadgen store why src/assembly.py\ncadgen step snapshot STEP/assembly.step tmp/assembly.png\n```\n\nThis is a placement example; add the fastening features the actual design needs.\nRunning the root builds its stale children. After a child changes, rerun the root.\nPlaced children use `.moved()` or `Location * shape` to keep their geometry shared.\nFor mating datums and joint relationships, see [positioning](positioning.md).\n\n## Add capabilities only when needed\n\n| Need | Add |\n| --- | --- |\n| STL, 3MF or GLB output | A mesh decorator; a mesh-only model omits `@step`. See [exports](supported-exports.md). |\n| Shared factory or hole pattern | A plain helper under `src/lib/`, with `src/lib/__init__.py`. |\n| Left/right geometry | Mirror inline, or use separate models for independent outputs/reuse; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models). |\n| Subassembly | A model that calls its child models; use folders as the project grows. |\n| 2D drawing | A separate drawing model using `$dxf`. |\n| Vendor CAD | Preserve the source under the format's `imported/` folder; use `read_step` with an anchored path or a wrapper model. |\n| Durable requirement checks | `checks/` or the project's existing test directory; exploratory checks and images go in ignored `tmp/`. |\n\nOnly kinematics, intrinsic materials or animation require a STEP sidecar.\nDeclaring a mesh alongside STEP does not create one.\n\n## Project bookkeeping\n\nAdd a short `src/README.md` catalog for a multi-model project:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Base plate |\n| standoff.py | STEP/standoff.step | Repeated spacer |\n| assembly.py | STEP/assembly.step | Plate with two spacers |\n\nBuild the assembly with `python src/assembly.py` from the project root.\n```\n\nFollow the repository's version-control policy and the user's commit instructions.\nThe [layout reference](project-layout.md#version-control) suggests defaults and\nignore patterns for new projects. Create output folders and helpers only as needed.\n\nIf the project uses Git LFS for imported sources, scope its `.gitattributes`\nentries to the relevant files or formats, for example:\n\n```gitattributes\nSTEP/imported/** filter=lfs diff=lfs merge=lfs -text\n*.step.json text\n```\n\nConfigure Git LFS before adding files covered by those rules. A file starting with\n`version https://git-lfs...` is a pointer: `git lfs checkout STEP/imported`\nrestores objects already in the local cache; missing objects need fetching from\nthe project's LFS remote before CAD tools can read them.\n","skills/cad/references/repair-loop.md":"# Repair loop\n\nUse the failing command and geometry to localize the problem, correct the\nresponsible source or arguments, then rerun the failed and affected checks.\nPreserve specified features and dimensions; disclose any necessary deviation.\n\n## Source and build failures\n\n- **Syntax or import error:** use the traceback to correct the source or\n  dependency. Keep geometry creation inside model functions and their helpers.\n- **Script exits without outputs:** confirm `__main__` calls the decorated model,\n  that it returns a native shape, and that its decorators declare the intended\n  outputs. Function names are not discovered by convention.\n- **Unexpected freshness:** run `cadgen store why <model>.py`; check tracked\n  inputs and whether the parent was rerun after a child changed. See the\n  [model contract](step-generation.md#freshness-cadgen-store-why).\n- **Installation or version mismatch:** run `cadgen doctor <skill-dir>` and\n  follow the specific error. See [migrations](migrations.md).\n\n## Geometry failures\n\n| Symptom | Useful checks and possible remedies |\n| --- | --- |\n| Missing or invalid body | Check profile closure, cut placement, zero thickness and the first failing operation. Use the Python diagnostics in [inspection](inspection-and-validation.md). |\n| Missing hole/pocket | Check feature mode, selector, cut depth and the intended through-condition against the saved geometry. |\n| Wrong scale or extents | Check units, radius versus diameter, primitive alignment and extrusion direction; measure the relevant dimensions. |\n| Fillet/chamfer failure | Check edge selection and local space. Consider feature order or equivalent profile construction. Change a required radius only as an explicit design decision. |\n| Loft failure or distorted surface | Inspect wire correspondence, winding, self-crossings and disconnected sections. Prefixes or adjacent pairs can localize the problem; a successful pair does not prove a valid full loft. |\n| Slow Boolean | Time the suspect operation or inspect a stack sample. Consider simpler surfaces, tool extents, batching or staged cuts while preserving the required feature. |\n| Selector no longer matches | Reopen with `read_scene`, enumerate candidates, and measure their geometry. Numeric refs are revision-scoped. |\n| Assembly placement mismatch | Check local datums, fixed/moving order, axis direction and transform composition. Measure the required signed gaps and angles on saved geometry. |\n\nVersion-specific pitfalls and construction alternatives are in\n[build123d modeling](build123d-modeling.md). For assembly corrections, see\n[positioning](positioning.md).\n\n## Viewer and snapshots\n\nShow the result as in [Show the model](../SKILL.md#show-the-model). With the CLI viewer,\n`cadgen viewer --help` lists its options. If showing fails, report the failure and use\nPython geometry checks plus snapshots.\n\nSnapshot commands take saved documents, not model scripts. Run the model first\nwhen its output is missing; otherwise verify the input format/path, Chromium\ninstallation and the reported options. Missing derived render data is compiled\non demand; adjacent GLB/topology files are not a prerequisite. Try a single\nsupported view when isolating a request error, then restore the views needed\nfor the review. See [snapshot review](snapshot-review.md).\n\n## Check the repair\n\nWhen a repair could affect other geometry, compare before/after documents using\n`read_scene` and the dimensions, volumes or relationships that should remain\ninvariant. Resolve labels independently in each revision. For shape changes,\nnative Boolean differences can show both added and removed material.\n\nReport what failed, what was repaired and verified, and which requirements\nremain untested or unresolved. Identify any artifact that remains usable.\n","skills/cad/references/snapshot-review.md":"# Snapshot review\n\nRead this file when choosing visual checks for saved STEP/STP or mesh outputs.\nUse `cadgen step snapshot` for STEP, or the corresponding `stl`, `3mf` or `glb`\nsnapshot command for meshes (see [mesh exports](supported-exports.md)). The\nCAD topology, selection and motion controls below apply to STEP; clipping applies\nto every 3D input, and exploding to a STEP or a GLB of several parts.\n\n## Policy\n\nEvery created or visibly updated part or assembly gets at least one reviewed\nPNG snapshot of its STEP, or its mesh when no STEP is declared. Passing geometry\nchecks does not waive this visual review. Use the format's snapshot command.\nFor a STEP pose or clip frame, pass `--kinematics` and/or\n`--animation CLIP --time SECONDS`. A motion review may additionally need video;\nsee [kinematics](kinematics.md#reviewing-motion).\n\nSkip saved snapshots only when no visible geometry was created or updated, or no valid artifact exists:\n\n- pure format/export requests where geometry is unchanged\n- source changes that do not alter visible geometry\n- inspection-only tasks (for example direct measurement questions) that create or update nothing\n- failed Python or STEP generation before a valid artifact exists\n\nWhen skipping, report the reason and the deterministic evidence that still ran.\n\nDo not loop on snapshots. Rerender only when a source repair changed visible geometry or when a specific visual finding needs confirmation.\n\n## Packet sizing\n\nChoose views that expose the features being checked. One may be enough;\nadd an opposing view for hidden exterior features, an orthographic view for\na pattern or silhouette, or a clipped view for internal geometry. No fixed set of\nviews proves every face or feature is correct.\n\n## Small packet\n\nFor example, a four-view comparison can use one JSON job:\n\n```json\n{\n  \"input\": \"models/part.step\",\n  \"mode\": \"view\",\n  \"outputs\": [\n    { \"path\": \"/tmp/render/iso.png\", \"camera\": \"iso\" },\n    { \"path\": \"/tmp/render/iso_opposite.png\", \"camera\": { \"direction\": [-1, 1, -0.8] } },\n    { \"path\": \"/tmp/render/top_ortho.png\", \"camera\": \"top\" },\n    { \"path\": \"/tmp/render/front_ortho.png\", \"camera\": \"front\" }\n  ],\n  \"output\": { \"viewLabels\": true, \"padding\": 0.12, \"sizeProfile\": \"diagnostic\" }\n}\n```\n\nUse only the views relevant to the design. Opposed isometric views reveal more\nexterior faces, but neither they nor orthographic views reveal all occluded geometry.\n\nSet `input` to the saved STEP/STP artifact using a relative or absolute path\n(documents only: run a `.py` model first). With no explicit display settings,\nsnapshots use the `solid` preset, Light appearance and an orthographic isometric\ncamera. Every mode defaults to 1600x1200 (the `diagnostic` profile). Job `mode`\nis `view` or `list`; display presets are a separate choice.\n\n`--size-profile` (`output.sizeProfile` in a job) is one of exactly these names;\nanything else is refused with this list:\n\n| Profile | Size |\n| --- | --- |\n| `simple` | 1200x900 |\n| `simple-square` | 1024x1024 |\n| `diagnostic` (default), `labeled` | 1600x1200 |\n| `assembly` | 1800x1200 |\n| `assembly-large` | 1920x1440 |\n| `presentation`, `contact-sheet` | 2400x1600 |\n| `presentation-large` | 2800x1800 |\n\n`--width`/`--height` (an output's `width`/`height` in a job) override the profile\nwith a whole number of pixels from 1 to 8192; a larger request is refused rather\nthan clamped. With `--job` they size every output in the packet.\n\n`--display` takes one of the Viewer's six Display modes, each drawn at its own\ndefaults: `solid` (the default: shaded, with edge linework), `shaded` (the same\nsurfaces, no edges), `parts` (Solid with each part its own colour), `xray`\n(translucent surfaces with occluded edges visible), `hidden-line` (linework with\noccluded edges suppressed), and `wireframe` (edges only); they use the shared\nprojection, orthographic by default. `--grid` draws the measuring grid (red X and\ngreen Y axes) under the model in any mode; it is off by default. `--preview` draws\nPreview's photographic look instead, in perspective with studio lighting, at\nPreview's defaults; it draws no grid and is view mode only.\n\n```bash\ncadgen step snapshot STEP/part.step tmp/review.png --display hidden-line --grid\ncadgen step snapshot STEP/part.step tmp/review.png --preview\ncadgen step snapshot STEP/part.step tmp/review.png --display display.json --camera front\n```\n\n`--display` (and a job's `display`) also takes the Viewer's display object, inline\nor as a JSON file: `mode`, boolean `grid`, `appearance` (`light`, the CLI default,\nor `dark`), `camera` (`projection`: `orthographic` or `perspective`; `focalLength`:\n20–200 mm), and the shared tools `clip` and `exploded`. A mode has no settings of\nits own, and anything else is refused.\n\n`--camera` (or a job's top-level or per-output `camera`) sets pose and framing\n(`preset`, `position`, `target`, `up`, `direction`, `zoom`,\n`orthographicHalfHeight`); projection and focal length belong in\n`display.camera`. Every 3D input takes `clip` (`at` in the file's own unit), and\n`exploded` takes a STEP or a GLB of several parts. `shaded`, `xray`, `hidden-line`\nand `wireframe` are STEP-only; meshes and robot descriptions take `solid` or\n`parts`, `--grid`, or `--preview`. DXF uses `--appearance` and has no scene display\nmodes.\n\n`camera` is a preset (`front`, `back`, `left`, `right`, `top`, `bottom`, `iso`),\nan `azimuth:elevation` pair, or a camera object. `output` takes `sizeProfile`,\n`padding` (0–0.15), `renderScale` (1–3) and the booleans `viewLabels`,\n`tightFrame` and `transparent`. Top-level `timeoutSeconds` is a positive number\nand `scale` is `cad` or `urdf`. Unknown keys and out-of-range values are refused.\n\nFor close macro views in normal CAD, a JSON job can set `quality.tessellation` to\n`{\"chordTolerance\": 0.0005, \"angleTolerance\": 0.10}`. Chord tolerance is\nrelative to each component's bounding diagonal; angle tolerance is radians.\nThese positive numeric overrides retessellate the exact STEP surfaces and use\nseparate shared-cache entries. They do not change the STEP geometry or a model's\ndeclared mesh-export tolerances. Use them only when visible faceting needs finer\nsampling; lower tolerances cost more memory and render time. `chordTolerance`\nmust be at least `0.00001` and `angleTolerance` at least `0.005` — finer than\nthat exhausts the renderer instead of improving the image, and the job is\nrefused.\nExisting mesh documents cannot be retessellated this way. The explicit top-level\nsampling request works in every display mode. When it is omitted, `--preview` draws\nat Preview's final LOD.\n\n\n### Flags and job keys\n\nA JSON job's keys are the flags without their dashes, and the job is the only\nplace some shapes exist. `--animation CLIP --time SECONDS` is ONE request, so a\njob carries it as one `animation` object — `time` is not a top-level job key:\n\n```json\n{\n  \"input\": \"models/arm.step\",\n  \"kinematics\": \"open\",\n  \"animation\": { \"clip\": \"demo\", \"time\": 2.0 },\n  \"outputs\": [{ \"path\": \"/tmp/render/demo_t2.png\", \"camera\": \"iso\" }]\n}\n```\n\n`clip` names a clip embedded in the document sidecar's animation and is required;\n`time` is seconds, finite and >= 0, defaulting to 0. A bare clip name is the\nFLAG's spelling, not the job's: `\"animation\": \"demo\"` is refused, as is any key\nthe job does not support — the error lists the supported set.\n\nA `\"video\"` object beside it renders the clip's SPAN into the `.mp4` or `.gif`\nthe single output names, instead of one frame: `{\"fps\": 30, \"seconds\": <what is\nleft of the clip>, \"start\": 0, \"quality\": \"review\", \"loop\": true}`, every key\noptional and every other key refused. It needs `animation`, refuses an\n`animation.time`, refuses a `start` past the end of the clip, and needs ffmpeg\ninstalled. See `kinematics.md`, \"Rendering the whole clip\".\n\nIn a JSON job these two flags are the one exception to \"job key = flag name without dashes\": they nest under a job-level `selection` object, and a top-level `\"hide\"` or `\"focus\"` is rejected as an unknown key. Selection applies to the whole job, not to one output — to hide or focus parts for a single view, give that view its own job in a `jobs` array.\n\n```json\n{\n  \"input\": \"STEP/assembly.step\",\n  \"mode\": \"view\",\n  \"selection\": { \"hide\": [\"#o1.3\", \"#o1.4\"] },\n  \"outputs\": [{ \"path\": \"tmp/render/without_covers.png\", \"camera\": \"iso\" }]\n}\n```\n\n`\"selection\": { \"focus\": [\"#o1.2\"] }` is the `--focus` form; `focus` and `hide` are the only selection keys. Every other flag keeps the plain rule (`--kinematics` → `\"kinematics\"`, `--animation CLIP --time S` → `\"animation\": {\"clip\": ..., \"time\": ...}`).\n\nWith `--job FILE`, other flags override every job in it; there is no stdin form.\nA TARGET given with `--job` replaces every job's `input`, so one job file renders\nthe same views over another model; OUT is ignored, and the jobs' outputs name\nthe files:\n\n```bash\ncadgen snapshot STEP/bracket_v2.step --job views.job.json\n```\n\n### Annotations and Quick Edit views\n\n`--annotation FILE` (a job's `annotation`: a path or the object) draws a person's ink\nover the model as they drew it in the CAD Viewer: its marks are in model\ncoordinates and stay put whatever explodes or poses the model, and are cut by a clip; what\nthe model hides of them is drawn faint through it. A mark lies on the surface it was\ndrawn on, or on that surface's plane (flat across a curved face, carried on in the air past the\nmodel's edge), so its points need not be coplanar.\nEach mark's `touches` says what it was drawn over, most first: the faces and edges\nas `@cad[...]` references (`ref`) where the Viewer could name them, each with its\npart — read those to know what a mark is about.\nA DXF annotation lies on the drawing plane; `--mode list` takes none.\n\nA Quick Edit prompt carries a snapshot packet captured only when Copy, Queue or\nSend is pressed. Up to 1 KiB of compact UTF-8 JSON is included directly in the\nprompt; larger packets are saved to a temporary `.snapshot.json` and named by\nabsolute path. Save inline JSON to a temporary `.snapshot.json` before calling\n`cadgen snapshot --job <file>`; the CLI takes a file path. Render and inspect supplied\nannotations before editing. A view without annotations is optional context: render it\nonly when useful to the request; its presence alone does not require inspection.\nThe `jobs` list includes the current view and every distinct drawn view with\nremaining ink. A matching drawn view uses the exact current framing in the export;\notherwise `current view` is appended. Text and selections without\nink still capture the current view. Shared `input` and inline `annotation`\nlive at the top level once. An individual job can override any\nshared field (whole-value replacement, not a merge); empty `marks`\nsuppress shared marks. Captured 3D views preserve projection and lens. No PNGs are\nrendered or attached at handoff. Input and output paths are absolute, so the packet\ncan be replayed from another working folder. Annotation chips do not\nexport individual references; their data travels in the snapshot packet. A Quick Edit from any 3D file replays: a robot's job carries its\n`jointValues` and the parts hidden in its Links panel (`selection.hide`), a GLB's or a\n3MF's its `display.clip`, and a multi-part GLB's its `display.exploded`. `cadgen snapshot --job <job\nfile>` renders those views again: after an edit, run it to see the change from\nwhere the person looked, with their annotation still on it. Add a TARGET to aim the\nsame views at another document.\n\n## Clipped views\n\nUse the viewer's Clip setting to reveal internal geometry in a normal view snapshot.\n`display.clip` takes `enabled`, `axis` (`x`, `y` or `z`), `at` in the file's own unit\n(a STEP's millimetres; a GLB's, URDF's and SDF's metres; a 3MF's declared unit), and\n`invert`. The cut stays capped and uses the same scene as the viewer.\n\n```bash\ncadgen step snapshot STEP/housing.step tmp/cut.png --camera back --display '{\"clip\":{\"enabled\":true,\"axis\":\"y\",\"at\":12.5}}'\n```\n\nOUT's extension picks the format: a view writes `.png`, and a video writes\n`.mp4` or `.gif`. Other extensions are refused instead of writing PNG bytes under them.\n\n## Output paths\n\nName the file and you get that file:\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\n# then Read tmp/review.png\n```\n\nOUT (and an output's `path` in a JSON packet) is written exactly as given,\nrelative to the working directory. Check the command's exit before reading. A\nrefused request — an unknown key or value, a setting this kind of input cannot\ntake, the wrong door, conflicting options, an unknown pose, clip or joint name —\nleaves an existing file untouched, for every job in a packet. Once the request is\naccepted the target is cleared before anything is built, so a failed build or\nrender (and an occurrence ref the model does not have, which needs the built\ntree to check) leaves no file; successful output is written atomically.\nReuse `tmp/review.png` during iteration, or name before/after images when both\nare needed for comparison.\n\nPass a directory (`tmp/` as OUT, or an output `path` that is one) only when the name does not matter: a timestamped name is generated inside it, and that is the one case where you read the path from the `saved snapshot:` line.\n\n## Targeted additions\n\nAdd views only when the brief or a failure mode calls for them:\n\n- reference-image reproduction: one snapshot from the reference image's viewpoint for side-by-side comparison\n- `display.clip` with a camera facing the cut: shell, bore, internal cavity, passage, blind hole, enclosure, or wall/floor relationship (see [Clipped views](#clipped-views))\n- `display.mode: \"solid\"`: shaded CAD view with visible edge linework\n- `display.mode: \"shaded\"`: shaded surfaces without the edge overlay\n- `display.mode: \"xray\"`: translucent surfaces with hidden/occluded edges visible\n- `display.mode: \"hidden-line\"`: line-focused review with occluded edges suppressed\n- `display.mode: \"wireframe\"`: edge-only review for internal overlap or interference\n- `display.grid: true`: a measuring grid under the model in any display mode, for reading size and placement at a glance\n- labeled or annotated review: use supported CAD Viewer refs, selections, screenshots, or GUI review links\n\nExploded or labeled review is an intent, not a render mode. Satisfy it through supported CAD Viewer mechanisms, supported JSON job settings, or the GUI link.\n\n## Diagnostic review\n\nVisual review is diagnostic, not authoritative. Convert every visual concern into a follow-up geometry check before using it as a validation claim:\n\n- hole pattern appears asymmetric -> measure hole centers and compare offsets\n- lid, child part, or occurrence appears offset -> inspect frames and mating deltas\n- gusset, boss, standoff, rib, or plate may be floating -> inspect solid count, labels, connectivity, contact, or relevant distances\n- cavity, bore, or blind hole looks wrong -> review a clipped view, then measure wall thickness, depth, or through-condition\n- repeated pattern looks uneven -> measure pattern centers, angular spacing, or occurrence frames\n\nFinal reports name what the snapshots showed, or the documented skip reason, and which deterministic checks support any visual finding. The PNGs are for your review; the user sees the model in the viewer, so attach one only when they ask for an image.\n","skills/cad/references/step-generation.md":"# The model contract and STEP generation\n\nRead this file when authoring or rebuilding a model script, composing models\ninto assemblies, deciding what a rebuild tracks, or working with imported\nSTEP/STP files.\n\n## The model script is the tool\n\nGeneration has no CLI. A model is a plain Python script whose `__main__` calls\nthe decorated function; that call builds it:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step\ndef bracket():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython bracket.py                 # builds bracket.step (and the result tree in the store)\npython bracket.py --force --json  # per-run flags ride the script's argv\n```\n\nEvery run updates the cached native geometry and writes the outputs declared\nby the model. Display data is derived on demand. Unchanged sources are a\nfast no-op. The default `.step` is the sibling `<stem>.step`; relocate it\ndurably with `@step(out=\"path/to/out.step\")` (relative to the script). There\nis no per-run output override: a model has one set of outputs, declared in its\ndecorators, and the store's record of it is keyed by the script.\n\nRules the decorator enforces:\n\n- **The decorator only declares.** Nothing runs at decoration or import time.\n  To build by running the file directly, end it with\n  `if __name__ == \"__main__\": <model>()`. Imported models build when called.\n- **A top-level call builds.** Calling the decorated name when no build is in\n  progress (`__main__`, a REPL) runs the pipeline; a failed build exits with\n  the pipeline's code. A conventional real-file `__main__` bare call such as\n  `plate()` finishes every output without loading its discarded geometry back\n  into that process. It takes no arguments.\n- **A call inside a build composes.** From another model's body the same name\n  returns the shape: the child is built if it is stale (writing ITS outputs\n  and record), otherwise loaded from the store, and either way its result is\n  linked into the parent's. Composition is ordinary Python; there is nothing\n  to cache by hand and no composition API.\n- **Use one decorated model per entrypoint file.** Match the script's stem in\n  its declared outputs: `plate.py` → `STEP/plate.step`, `STL/plate.stl`, etc.\n  Stack output decorators on that one function; put shared factories in helper\n  modules and independently exported configurations in separate entrypoints.\n  The model is addressed by its script path (`python plate.py`,\n  `cadgen store why plate.py`). This is the skill's project convention;\n  helpers and package `__init__.py` files are not model entrypoints.\n- **Calling a model from plain Python returns its geometry.** Outside a build,\n  `plate()` builds (or finds current) and returns the model's tree as a\n  `Compound` — what a parent composing it would get — so a script, a notebook\n  or a REPL can read bounds, faces or volumes straight off a model. Assign or\n  otherwise consume the call when using that return. Only a proven discarded\n  real-file `__main__` bare call skips this materialization; instrumented,\n  interactive and ambiguous calls keep returning geometry. A drawing returns\n  `None`.\n- **A model takes no parameters.** It is one configuration of one set of\n  outputs, so there is nothing for an argument to select; the decorator\n  refuses a parameter list. Parametric geometry is a plain factory the model\n  calls:\n\n  ```python\n  from __future__ import annotations   # keeps `-> bd.Shape` a string, not an import\n\n  from cadgen import build123d as bd\n  from cadgen import step\n\n\n  def _bracket(width: float, thickness: float) -> bd.Shape:\n      return bd.Box(width, 10, thickness)\n\n\n  @step\n  def bracket():\n      return _bracket(width=40.0, thickness=6.0)\n\n\n  if __name__ == \"__main__\":\n      bracket()\n  ```\n\n  A second configuration is a second model (`bracket_wide.py`), with its own\n  outputs — the way two part numbers are two parts. Values a model shares with\n  its drawing or its assembly live in module constants (`WIDTH = 40.0`) that\n  the siblings import.\n- **The return is a bare build123d `Shape` and nothing else** — a dict return\n  is refused. The return IS the geometry: a `Compound` placing children is\n  packaged as occurrences (linked where a child is another model's result), a\n  single solid as one component. `read_scene` exposes that saved occurrence\n  hierarchy; manufacturing intent is not inferred from it.\n- **Outputs are what the decorators declare.** `@step` writes the `.step`.\n  Mesh outputs are `@stl`/`@threemf`/`@glb` stacked on the model, tolerances\n  on the decorators (`supported-exports.md`). **A model may declare no STEP at\n  all**: `@stl`/`@threemf`/`@glb` with no `@step` is a full model — same tree,\n  record, build, no-op and composition — that writes its meshes and no\n  `.step`, no sidecar. STEP is one output kind, not a requirement.\n- Options on `@step`: `out=`, `mesh_tolerance=`, `mesh_angular_tolerance=`,\n  `kinematics=`, `materials=`, and `animation=` (`kinematics.md`). **No decorator argument changes the\n  geometry a model produces**: they decide where the files land, how they are\n  written, and what the sidecar declares. `animation=` is a self-contained\n  JavaScript ES module string embedded in that sidecar. `materials=` is\n  `{\"definitions\": {id: material}, \"assignments\": [{\"targets\": [\"#label\", \"#group\"], \"material\": id}]}`;\n  definitions remain available even when unassigned, and every target must\n  resolve exactly. See `build123d-modeling.md` for the material channels.\n  Everything a model declares about itself lives in its decorators, and a\n  child's intrinsic materials inherit through its pinned tree. Kinematics and\n  animation are document-scoped and never inherit into a parent.\n\n**Imports:** use `from cadgen import build123d as bd`, a lazy re-export,\nso a current model can finish before loading the CAD kernel. Keep geometry,\nCAD file reads and `bd` attributes out of module-level constants/defaults.\nUse `from __future__ import annotations` for annotations such as `-> bd.Shape`.\nRaw `import build123d` works but pays the import cost on every rerun.\n\n**A model runs like `python script.py`.** Its folder is on `sys.path` for the\nwhole build, plus your `PYTHONPATH` — cadgen adds nothing else and infers no\nproject root — so an import inside the body, or inside a helper the body calls,\nresolves exactly like one at module top — and the file it loads is hashed when\nit executes, so it is in the closure either way. Prefer module-top imports for\nreadability and so the static scan sees the graph up front; a lazy import is\nnot an error.\n\n## Generated vs imported STEP\n\nThese two terms classify a STEP file by what its source is:\n\n- A **generated STEP file** has a model script as its source. The STEP is a\n  *derived output*; the script is what you edit and re-run.\n- An **imported STEP file** is its own source: authored or downloaded\n  elsewhere. There is nothing upstream to regenerate.\n\nA STEP model with `kinematics=`, `materials=`, or `animation=` writes a sidecar\nbeside its output (`<name>.step.json`) containing the corresponding resolved\nsections, bound to the saved STEP's byte hash. A model with none writes no\nsidecar; its store record makes reruns no-op. Mesh declarations stay in that record.\nCompiling an imported STEP preserves any authored sidecar. The written STEP file\nitself carries NO cadgen metadata and no link back to source code, ever — a\nbare artifact copied anywhere is a plain importable file, and every door\nresolves it by its bytes, so a moved or copied document renders identically to\nits twin.\n\n## Composing on other parts: children and inputs\n\nA model that builds on another part wires it in one of two modes. Choose\ndeliberately:\n\n- **A CHILD (the default)** — the other part is a model in this project:\n  import its function and call it. A child edit flows into the parent on the\n  parent's next rebuild; there are no exported bytes to keep in sync. Never\n  route a generated child through its exported `.step`.\n- **An INPUT** — the other part is a document, not source: a purchased or\n  downloaded part, or a generated part the user has EXPLICITLY asked to\n  decouple (export it once, then treat the export like any other document).\n  Read it with `cadgen.read_step`, below.\n\n### Children\n\nA child is just an import: model scripts are real modules, and\n`from widget import widget` binds the model with no build side effects.\nCalling `widget()` inside the parent's body builds the child when it is stale\n(writing the child's own outputs) or loads its result from the store, and\nreturns the shape. What comes back is GEOMETRY only — tree, labels, colors,\nplacements. A child's sidecar content (its mates, kinematics, animation) never\nrides up into the parent: declare what the assembly needs on the assembly.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom link_pin import link_pin   # importing binds; never builds\n\n\n@step(out=\"../STEP/link_arm.step\")\ndef link_arm():\n    bar = bd.Box(40.0, 8.0, 4.0)\n    bar.label = \"bar\"\n    pin = link_pin()                                   # built if stale, else loaded\n    left = pin.moved(bd.Location((-15.0, 0.0, 2.0)))   # placed: the parent LINKS to the pin\n    left.label = \"pin_left\"\n    right = pin.moved(bd.Location((15.0, 0.0, 2.0)))   # placed again: a second link, one tree\n    right.label = \"pin_right\"\n    return bd.Compound(children=[bar, left, right], label=\"link_arm\")\n\n\nif __name__ == \"__main__\":\n    link_arm()\n```\n\n**Link or component.** Place a child's shape as it came back — `moved()`,\n`Pos/Rot/Location * child`, relabelled, recolored — and the parent's result\nLINKS to the child's tree (stored once, shared by every parent; two placements\nare two links to one tree). Modify it (a boolean, a mirror, extracting a\nsub-shape) and the parent owns that geometry as its own components; the\ndependency is tracked either way. **Never `located()`** for placement: it\ndeep-copies the geometry, which makes it the parent's own component instead\nof a link (`positioning.md`). Put geometry changes that belong to the child in\nthe child's file or its factory.\n\nChild calls submit work and return lazy shapes. Placements, labels and colours\ncan be set before geometry is ready; geometry queries wait for the child.\nIndependent children can build in parallel, subject to runtime admission.\nNo scheduling code is needed in the model.\n\n**Dependency is pull.** A parent depends on each child by RESULT: its record\npins the child's tree hash, so a child edit that yields identical geometry\nleaves the parent current, and an edit that does not reach a child skips that\nchild's Python and kernel work entirely. **Rebuilding a child does not rebuild\nthe assemblies that use it** — run the parent to pick up the change\n(`python src/robot.py` builds whatever is stale beneath it and links the\nrest). A parent finished against a child that changed during its build says so\n(`already stale: … rerun`).\n\n### What a rebuild tracks — models by result, constants by value, functions by reach\n\nWhat an importer TAKES from a model file decides how that file counts:\n\n- **`from widget import widget`** (the model function), called → tracked by\n  RESULT: the parent pins the child's tree, so an edit inside `widget()`'s body\n  rebuilds the parent only when the child's geometry changes. Importing\n  `widget.py` still runs its top level in the parent's process — its imports,\n  module-level code and any decorator argument that is not a literal — so an\n  edit there rebuilds the parent too (a literal such as `out=\"...\"` does not).\n  Keep a model file's top level to imports, constants and definitions.\n- **`from widget import WIDTH`** (a module-level literal: a number, string,\n  bool, `None`, or tuples/lists/dicts of those) → tracked by VALUE: a\n  comment or body edit in `widget.py` leaves the importer current; only a\n  changed value rebuilds it. A value computed by module-level code is\n  import-time code: editing that expression rebuilds the importer too.\n- **Anything else** from a model file (a helper function, a `bd.` object, an\n  expression) → tracked by FILE: the whole file joins the importer's source\n  closure, and any edit to it rebuilds the importer. Shared helpers therefore\n  belong in `lib/` (a plain module, in the closure of every model that\n  reaches it — by the functions and constants the model can actually run, so\n  editing a helper no model calls rebuilds nothing), and shared constants may\n  live in a model file or in `lib/`.\n\nInputs join the closure too: every data file the build opens is hashed as a\nbuild input, whatever opens it (below). A new\nfile that changes what an import finds — an `__init__.py` added to a folder, a\npackage beside a module, a same-named module earlier on the path — also makes\nthe model stale. Embedded `animation=` source and named\n`materials=` are decorator annotations. Imported values and helper calls\nremain ordinary source dependencies.\n\nEvery decorator argument is ordinary Python, evaluated when the module is\nimported: `out=f\"{FOLDER}/{NAME}.step\"`, `mesh_tolerance=TOL` with `TOL` from\n`lib/`, a path built from a constant — all fine, and nothing is read off the\nsource text. The values feeding them are tracked like any other input (a\n`lib/` helper by reach, a model-file constant by value), so changing the\nconstant behind an `out=` makes the model stale. The module top must still stay\nkernel-free so checking the model's declarations stays cheap.\n\n### Splitting for rebuild speed\n\ncadgen never caches work inside a model: a model whose inputs changed runs\nfrom scratch, and one whose inputs did not is skipped whole. Rebuild speed\ntherefore comes from how the project is split:\n\n- Give a part its own model when it dominates its model's build time, takes\n  more than about 15 s, or is edited independently of its neighbours: a thin\n  entry file whose `@step` function calls a factory in `lib/`, placed by its\n  parent like any child. An edit then rebuilds that part and relinks the\n  parent; its siblings stay current, and stale children build in parallel. One\n  heavy casing or shell inside a model of many light parts is the case that\n  pays most: edits to the light parts stop paying for it.\n- Keep geometry in `lib/` factories. A model reruns only when code it reaches\n  changes, so an edit to one factory leaves the models that never call it\n  current.\n- One entry file per expensive model: every `@step` function in a file is\n  stale when any line of that file changes.\n\nA part that builds in a second or two gains nothing from its own file; check\ntimings with `--verbose` before splitting further.\n\n### Annotation caching\n\nAnnotation-only edits may reuse cached geometry; computed or imported\nannotations remain tracked dependencies and may require a rebuild.\n\n### Models inside a package\n\nA model file may live inside a Python package (folders with `__init__.py`).\ncadgen runs it under its dotted name, so relative imports (`from .parts.washer\nimport washer`) resolve whenever cadgen loads the model: as a child of another\nmodel, or when you run it as a module (`python -m pkg.stack`). Running the file\nby path (`python pkg/stack.py`) is Python's own limit, not cadgen's: Python\nexecutes it as `__main__` with no package, so a relative import fails before\ncadgen is involved; use `-m` or absolute imports for a file you run directly.\n`PYTHONPATH` still declares any import root beyond the script's own folder;\ncadgen adds nothing of its own.\n\n### Mirrored geometry and reusable models\n\nMirror geometry inline when it belongs to the current model. For example,\n`right = bd.mirror(left, about=bd.Plane.YZ)` needs no separate model file.\nIn cadgen, reflection creates geometry rather than a rigid placement of the\noriginal child: a mirrored child becomes parent-owned components, while its\nsource model remains a tracked dependency. The parent's complete result is\nstill cached, and an unchanged parent remains a no-op.\n\nUse a separate model when the mirrored part needs independent outputs or reuse\nacross assemblies or parent rebuilds. Its result can then be linked and placed\nlike any other child. A separate model is also the unit of reuse: cadgen\nnever caches work inside a model, so an unchanged model is skipped whole and\na changed one runs whole (see [Splitting for rebuild speed](#splitting-for-rebuild-speed)).\n\nFor independently exported left/right variants, a shared factory is one option:\n\n```python\n# src/lib/bracket_shape.py — the factory (plain module, no decorator)\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\n\n\ndef side_bracket(mirrored: bool = False) -> bd.Shape:\n    body = bd.Box(40.0, 10.0, 6.0) - bd.Pos(12.0, 0.0, 0.0) * bd.Cylinder(2.5, 6.0)\n    return bd.mirror(body, about=bd.Plane.YZ) if mirrored else body\n```\n\n```python\n# src/bracket_left.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_left.step\")\ndef bracket_left():\n    return side_bracket()\n\n\nif __name__ == \"__main__\":\n    bracket_left()\n```\n\n```python\n# src/bracket_right.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_right.step\")\ndef bracket_right():\n    return side_bracket(mirrored=True)\n\n\nif __name__ == \"__main__\":\n    bracket_right()\n```\n\nChoose between inline geometry and separate models from the outputs and reuse\nthe project needs. Either can participate in the containing assembly's kinematics.\n\n### Inputs: reading a STEP file the model does not generate\n\nPrefer `cadgen.read_step` to `build123d.import_step`: it returns the same\nnative shape with the document's colours, and reads warm from the store when it\nalready holds the file's tree. Either way the file is a build input: replacing\nthe vendor STEP makes the model stale on its own, with no `--force`.\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step\ndef rig():\n    motor = read_step(_HERE / \"imported\" / \"vendor_motor.step\")   # recorded input\n    ...\n```\n\nAn imported part is an INPUT, not a model: nothing links to it and it has no\nrecord. To make it first-class — so assemblies link to it, so it has its own\noutputs and declarations — wrap it in a model of its own:\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step(out=\"../STEP/servo.step\")\ndef servo():\n    return read_step(_HERE / \"..\" / \"STEP\" / \"imported\" / \"sg90_servo.step\")\n\n\nif __name__ == \"__main__\":\n    servo()\n```\n\n**Never `read_step` your own output.** A model that reads the `.step` it is\nabout to write is not a loop — it is a model whose input changes every time it\nruns, so the gate can never say \"current\", every build is a full rebuild, and\nthe geometry depends on what the last run happened to leave on disk. Keep\nsource documents where the model cannot write them — placement policy belongs\nto `project-layout.md` (`imported/`). Input path and output path being different\nfiles is the whole rule. If the geometry you want is something the project\nalready builds, call that model instead of reading the artifact.\n\n### Inputs: a data file the model reads\n\nEvery file the build opens is recorded as it reads it, whatever reads it: a\nJSON routing atlas through `json.load`, a CSV of tap sizes, a table through\n`np.load`, a BREP `bd.import_brep` opens in C++, a project font `bd.Text` loads. Edit\n`atlas.json` and the model is stale on its own; rewrite it with identical bytes\nand it stays current, because the input is the content and not the mtime. A\nfolder the model globs is recorded too, so adding or removing a profile there\nrebuilds it. The model's own outputs in that folder do not count; another\nmodel's do, so a model that globs the folder its siblings build into reads stale\nonce after their first build. Nothing is declared.\n\nNot inputs: a file the build writes, the model's own outputs, the Python\nenvironment, and system files (fonts, time zones). A program the model starts\nas a separate process reads files on its own, unseen: keep that step outside\nthe model and read its result.\n\nFor structuring multi-part projects (folder layout, shared `src/lib/` code,\ncommit policy), read `project-layout.md` and `project-template.md`.\n\n## Freshness: `cadgen store why`\n\n`cadgen store why <model>.py` explains whether the model is current and which\nsources, imported constants, child results, cached objects or declared outputs\nchanged. It also accepts a generated STEP when the store knows its source.\nExit status is 0 for current, 1 for stale; `--json` returns the verdict as data.\n\n```bash\ncadgen store why src/frame.py\npython src/frame.py                 # rebuild the parent after a child changes\npython src/frame.py --force         # force this model's body to run\n```\n\nThe gate cannot track geometry selected by environment variables, the working\ndirectory, time or randomness. Put configurations in source/factory arguments;\nthe files a model reads are tracked on their own. After a runtime fix, force affected models\nif their cached results still reflect the old behavior.\n\n## Generated assemblies\n\nAn assembly is a model whose return places children (a `Compound` of parts\nor of other models' results); `read_scene` exposes the saved hierarchy.\nEdit the `.py` source to change an assembly; inspect or export its saved STEP\nto check what was actually written. Use native labels and choose transforms\nor native joints to express placement; see [positioning](positioning.md).\n\n## Imported STEP/STP files\n\nAn imported STEP/STP file needs no model script and no preparation step. Hand\nit straight to `read_scene`, `cadgen step snapshot`, or a mesh door:\neach compiles a tree from the file's bytes on first use (a job in the pool,\nshared with the CAD Viewer), and its part/assembly kind is inferred from the\nSTEP product hierarchy.\n\n```bash\npython tmp/check_imported.py  # read_scene(\"path/to/imported.step\")\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nTo produce STL/3MF/native GLB files from an imported STEP, pass it to the\nmatching format command. OUT is optional and defaults to a sibling with the\nrequested extension; read [mesh exports](supported-exports.md).\n\n### Re-emitting a foreign STEP as your own\n\nA STEP written by another kernel round-trips through cadgen with\n`cadgen step build IN OUT`: OCCT reads it, the tree is built, and the\ncanonical writer emits it, so OUT's bytes are deterministic and identical on\nevery run. The same command ANNOTATES a document that has no model script —\n`--kinematics` takes the whole space (`{mates, couplings, poses}`, the same\nvocabulary the decorator takes, as inline JSON or a `.json` path);\n`--materials` takes the named declaration as inline JSON or a `.json` path;\nand `--animation` takes a JavaScript module file or its source text. All\nthree resolve into OUT's unified schema-9 sidecar.\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\nRe-running is a no-op; editing only these annotations refreshes the sidecar\nwithout re-emitting a byte. Vendor metadata (PMI, GD&T) does not survive the round trip.\nOUT's root is named after OUT: the root `PRODUCT` and the header's `FILE_NAME`\ntake OUT's file stem (`STEP/hinge.step` → `hinge`), exactly as a model script's\ndocument is named after the file it writes — so the same input re-emitted to two\npaths yields two differently named documents. Names below the root are kept, so\n`#label` references to the parts keep resolving; do not address the root by the\nname IN carried.\n**Choose the door by how the model will evolve**: a shape you will keep changing\nbelongs in a model script (a thin wrapper that reads the foreign STEP), while\na one-shot canonicalization or annotation of a file you do not own is exactly\nwhat `step build` is for.\n\n## Optional-module assemblies\n\nA model that imports several part modules and SKIPS the ones that do not exist\nyet is a useful pattern for parallel work — the assembly stays renderable while\nindividual parts are still being written. It has one sharp edge.\n\nThe model's closure is computed from the modules it ACTUALLY IMPORTED at build\ntime. A module that did not exist during the build was never in the closure,\nso its later appearance cannot make the model stale, and every door keeps\nreading the old document's tree — no error, no warning. Run the model script\nwith `--force` after adding a previously absent part module.\n\n## After generation\n\n- Confirm the process succeeded and each declared output exists and is\n  non-empty (the stdout line names the document; `--json` adds the `tree`\n  hash).\n- Run the Python geometry checks selected for this design per\n  `inspection-and-validation.md`:\n\n```bash\npython tmp/check_model.py\n```\n\n## Progress and runtime diagnostics\n\nThe warm daemon is on by default. `cadgen daemon status` shows running and\nqueued work. Jobs may wait for CPU or memory admission; concurrency and worker\nlifetimes are runtime details, specified in the installed package's `STORE.md`.\nFor diagnosis, `CADGEN_DAEMON=0 python part.py` uses transient workers with the\nsame geometry/output contract.\n\nResults go to stdout; progress and errors go to stderr. Model runs accept\n`--json` for machine-readable results and `--verbose` for timings/full tracebacks.\nA `--json` result names its `document` by absolute path, and a `--json` failure\nis one `{\"ok\": false, \"error\": \"...\"}` line on stdout with exit status 1 — the\nsame envelope every `cadgen` command prints. `python part.py --help` lists the\nper-run flags. A script builds the models its `__main__` block calls; no flag\nselects one.\nFor long model bodies, optional `report` and `track` calls expose progress:\n\n```python\nfrom cadgen import report, track\n\n# Inside a model body:\nreport(\"ribs\")\nfor rib in track(ribs, label=lambda r: r.name):\n    ...\n```\n\nUse one cache store for normal project work. A separate `CADGEN_CACHE_DIR`\nis useful for isolated tests, but two stores writing the same project outputs\ncan cause repeated freshness misses. The store holds derived results only.\nPrefer a targeted `--force` or `cadgen store forget <model>.py` when diagnosing\none stale result; `cadgen store gc` removes unreachable cache data. The store\nkeeps itself under `CADGEN_STORE_MAX` (default 20 GB) by evicting its least\nrecently written derived entries; `cadgen store info` shows its size.\n","skills/cad/references/supported-exports.md":"# Supported exports\n\nRead this file when the user requests STL, 3MF, or native GLB output files from CAD geometry. For a `.step` file, run the model script (see `step-generation.md`) — a mesh door writes mesh formats only. For 2D DXF output, use the `$dxf` skill: a drawing is its own `<name>.py` declaring one `@dxf` function — one model per file, so a drawing never shares a script with a `@step` model.\n\n## Policy\n\nValidate the requested geometry and outputs. When a STEP is declared, inspect\nthat saved document; for a mesh-only model, check the returned native geometry\nand review its mesh. A mesh image does not establish exact dimensions or topology.\n\nNative GLB exports are ordinary glTF 2.0 binary files for external tools: Y-up, with one material per distinct part/face color. Do not confuse them with what the CAD Viewer renders from — the model's result tree in the store (`~/.cache/cadgen`: content-addressed exact-geometry components plus links to child trees), which every build writes and a mesh door never does.\n\n## Declare the exports the model always has\n\nStack `@stl`, `@glb` or `@threemf` on the model for maintained mesh outputs.\nFor example, a project-root `bracket.py` can declare:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import glb, step, stl\n\n\n@step(out=\"STEP/bracket.step\")\n@stl(out=\"STL/bracket.stl\")\n@glb(out=\"GLB/bracket.glb\")\ndef bracket():\n    return bd.Box(40, 20, 6)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\nRunning the script writes the STEP and declared meshes, and restores declared\noutputs that were deleted or edited. Declarations live in the model's store\nrecord. Export commands read saved documents and their appearance annotations,\nnever the model's output declarations. Mesh declarations alone create no sidecar.\n\n## A model with no STEP\n\nA model's outputs are whatever its decorators declare, and STEP is one output kind, not the primary. A function decorated with `@stl`, `@glb` or `@threemf` alone — no `@step` — is a full model: the same tree and record in the store, the same build, the same parallel children, the same no-op when nothing changed, the same composition (`spacer()` inside another model's body links its tree like any child). It writes its declared meshes and no `.step` (and no sidecar). Use it for a print-only part or a render asset; there is no requirement to write a STEP. Review it with its format's snapshot door (`cadgen stl snapshot STL/spacer.stl tmp/spacer.png`); `cadgen store why spacer.py` explains its freshness exactly as for a STEP model.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import stl\n\n\n@stl(out=\"STL/spacer.stl\", mesh_tolerance=4e-4)\ndef spacer():\n    return bd.Cylinder(6, 3) - bd.Cylinder(2.5, 3)\n\n\nif __name__ == \"__main__\":\n    spacer()\n```\n\nStacking order stays neutral: add `@step` above or below later and the same declarations ride along; the `.step` then joins the outputs.\n\nA decorator `out=` is the one intentional exception to native path semantics: on `@stl`, `@glb` and `@threemf` — exactly as on `@step` — a relative `out=` resolves relative to the SCRIPT, not the working directory. That is what makes a project relocatable: the declaration travels with the model and produces the same layout whatever directory the script is run from. Ad-hoc OUT arguments on the doors are cwd-relative instead, because they are one-shot and never persisted.\n\nFor maintained draft/print variants, keep the model's stem in separate subfolders:\n\n```python\n@stl(out=\"STL/draft/bracket.stl\", mesh_tolerance=8e-3)\n@stl(out=\"STL/print/bracket.stl\", mesh_tolerance=4e-4)\n```\n\n## Tool\n\nOne door per format — `cadgen stl build`, `cadgen 3mf build`, `cadgen glb build` — each taking a STEP/STP **document** and an optional output path:\n\n```bash\ncadgen stl build STEP/model.step                     # writes STEP/model.stl\ncadgen stl build STEP/model.step meshes/model.stl    # one ad-hoc export\n```\n\nExport commands take saved STEP/STP documents, never scripts. Omitting OUT\nwrites one file beside the input with the requested extension, whether the\ndocument is imported or generated. To build declared variants, run the model\nscript instead. An explicit OUT resolves against the working directory;\nabsolute paths and `~` are supported. For several formats:\n\n```bash\ncadgen stl build STEP/model.step\ncadgen 3mf build STEP/model.step\ncadgen glb build STEP/model.step\n```\n\nAn unchanged export is reported `current`. `--force` re-exports the document;\nit never rebuilds its source model. Missing cache data is compiled from the\ndocument on demand. Rerun the model first if its source has changed.\n\nUse an explicit OUT to choose another destination, including for imported files:\n\n```bash\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nA mesh door never writes a `.step` file. A generated model's STEP is the OUTPUT of `python <model>.py`; an imported model's STEP is already the file on disk.\n\n### Carrying a clip into the GLB\n\nGLB is the one mesh format with somewhere to put motion. `--animation` bakes a clip from the document sidecar's embedded animation into the file as glTF node animation, so an external viewer plays it:\n\n```bash\ncadgen glb build STEP/model.step meshes/model.glb --animation demo\ncadgen glb build STEP/model.step meshes/model.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\n`fps` controls keyframe sampling. Translation and rotation are supported;\nvisibility and opacity tracks are rejected unless explicitly dropped.\nDeforming tubes need the explicit morph-target option. Animated GLB requires\nan explicit OUT so the clip does not overwrite the default static export.\nSTL and 3MF have no animation export. See [kinematics](kinematics.md) for clip\nrequests and supported channels, or [tube deformation](animation-deformation.md)\nfor morph export options.\n\n## Rendering a mesh file\n\nEach mesh format also has a `snapshot` verb, with the same `TARGET [OUT]` grammar `cadgen step snapshot` uses:\n\n```bash\ncadgen stl snapshot STL/bracket.stl tmp/bracket_mesh.png\ncadgen 3mf snapshot 3MF/bracket.3mf tmp/bracket_3mf.png\ncadgen glb snapshot meshes/bracket.glb tmp/bracket_glb.png\n```\n\nA mesh carries no CAD topology. Its snapshot door accepts `--display solid`\n(the default) or `--display parts` (each object or mesh its own colour), `--grid`\nfor the measuring grid, and `--preview` for Preview's photographic view. Inline JSON\nand JSON files are the Viewer's display object: `mode`, `grid`, `appearance`,\n`camera`, `clip` and `exploded`. `display.clip` cuts it (`at` in the file's own\nunit: a GLB's metres, a 3MF's declared unit, an STL's own numbers) and\n`display.exploded` moves a GLB's nodes apart (a GLB of one is refused as it renders).\nThe `shaded`, `xray`, `hidden-line` and `wireframe` modes are STEP-only and are\nrefused by name, as `exploded` is for an STL or a 3MF.\nMesh doors do not have\n`--focus`/`--hide`, `--kinematics`, or `--animation`/`--time`; meshes have no\ncanonical CAD occurrences, kinematics,\nor render-module clips for those controls to act on. `cadgen step snapshot`\nrefuses a mesh input and names the door that takes it.\n\nThe picture is the scene the CAD Viewer draws for the same file, built by the same code:\nan STL or a 3MF is its objects in their colours, and a GLB is its own glTF scene (nodes,\nskins, morph targets and authored materials), so `--preview` shows the finish the\nfile authored. A GLB's clips play in the viewer; its snapshot is the file at rest.\n\nFor a mesh-only model, this is its required visual review. When a STEP is also\nproduced, review that document and render the mesh when tessellation or external\ntool output is the question. Pure format conversion with unchanged geometry\nfollows the skip cases in [snapshot review](snapshot-review.md).\n\n## Mesh tolerance\n\nMesh exports use the CAD Viewer's watertight tessellator on each component's\nexact surfaces. Viewer detail can vary with its level of detail settings;\nchoose export tolerances for the output's requirements. Boundary vertices lie\non the STEP edge curves, and identical export inputs produce identical bytes.\n\nUse these flags when the default mesh density is wrong for the part:\n\n```bash\n--mesh-tolerance FLOAT           # chord tolerance RELATIVE to each component's\n                                 # bounding diagonal (default 1.5e-3)\n--mesh-angular-tolerance FLOAT   # max normal spread across a triangle edge,\n                                 # radians (default 0.35)\n```\n\nOn a document export command, these flags select the tolerances for that export;\nomitting them uses the defaults above. The command does not inherit the source\nmodel's mesh declarations. On a model-script run, the same flags temporarily\noverride its declared tolerances — every declaration's, including one that sets\nits own (flag > declaration > `@step` > default): the declared meshes are re-cut\nat the flag's values for that run, and the next run without the flags restores\nthem. Use tighter values for curved-surface fidelity and looser values when a\ncoarser mesh meets the requirements. `--json` results report the effective pair,\nthe defaults included.\n\nLinear tolerance is relative, not an absolute deflection in millimetres, and is\nrefused above `0.05` (a twentieth of the bounding diagonal — past that the mesh\nno longer follows the part). For an absolute chord deviation of X mm on a part\nwhose bounding diagonal is D mm, pass X/D: 0.1 mm on a 200 mm part is `5e-4`.\nThe same bound applies to `mesh_tolerance=` on a decorator.\n\n## Workflow\n\n1. For maintained outputs, declare the mesh decorators and run the model script.\n2. For one-off exports, run the format command against the saved document.\n3. Check the requested geometry and output quality; report the files and checks.\n\nExample — the model declares its STL, and a coarse GLB is requested for review:\n\n```bash\npython bracket.py\n\ncadgen glb build STEP/bracket.step tmp/bracket.glb \\\n  --mesh-tolerance 5e-3 \\\n  --mesh-angular-tolerance 0.5\n\npython tmp/check_bracket.py  # check the saved STEP with read_scene\n```\n\nReport the actual output paths and checks performed, including the snapshot\nreview or its documented skip reason. Do not imply exact geometry was verified\nfrom a mesh image alone.\n","skills/dfam-check/SKILL.md":"---\nname: dfam-check\ndescription: Measure mesh files against Design for Additive Manufacturing (DfAM) rules and report printability findings per process (FDM, SLS, SLA/DLP, metal PBF, MJF). Use when the user asks whether a part is printable, wants overhang/wall-thickness/support analysis of an `.stl`, `.obj`, `.ply`, or `.3mf` mesh, wants a build-orientation recommendation, or wants DfAM redesign guidance before slicing with `$gcode` or regenerating geometry with `$cad`.\nlicense: MIT\n---\n\n# DfAM Check\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nUse this skill to produce conservative, evidence-backed DfAM reports for mesh\nfiles before slicing or printing. It measures geometry facts locally and\ncompares them against per-process design limits; it never slices, uploads, or\nstarts print jobs.\n\n## Geometry Inspection\n\nUse `scripts/dfam_tool.py` in the active project Python environment for all\ngeometry facts (install `requirements.txt` first — every run needs it). The tool is fact-only:\nit reports measurements and never emits pass/fail or readiness statuses.\nComparisons and verdicts belong to this workflow. Do not estimate wall\nthickness, overhang angles, or support volume by eye or from renders when the\ntool can measure them.\n\n```bash\npython scripts/dfam_tool.py measure part.stl --angle-limit 45\npython scripts/dfam_tool.py orientations part.stl --angle-limit 45\n```\n\nSet `--angle-limit` to the target process's self-supporting angle from\n`references/process-limits.md` before measuring, and re-run when the target\nprocess changes: the aggregate support-area facts are binned against it.\n\nSTEP/STP input is boundary-representation CAD, not a mesh. When the `$cad`\nskill is installed, export an STL sidecar with it first, then measure the STL\nhere. Report that remediation instead of attempting raw STEP parsing. `measure`\non a STEP exits 1 with `{\"error\": \"failed to load mesh: ...\"}`; that is the\nwrong-input signal, not a missing dependency — do not install extra mesh\nloaders to work around it.\n\nA fact family that cannot compute returns `{\"error\": ...}` in its place rather\nthan costing the report its other measurements — `wall_thickness` does this when\nthe dependency set is incomplete, `support_volume` on geometry with no convex\nhull. That report is PARTIAL: it carries `\"partial\": true`, names the families\nin `partial_sections`, and the command exits **2** (0 is a complete report, 1 a\nmesh that would not load at all). Treat every such object as an unmeasured fact\n(`❓ need more info`), never as a measurement of zero, and reinstall\n`requirements.txt` before comparing wall limits.\n\n## Workflow\n\n1. Collect print intent: target process, material, layer height, and any\n   machine or material datasheet the user can provide. If the process is\n   unknown, measure once with the default 45° limit, then present findings\n   per candidate process rather than guessing a single verdict.\n2. Read `references/process-limits.md` and select the limit column for the\n   target process. A user-provided machine/material datasheet overrides the\n   defaults; cite whichever source is used for every comparison.\n3. Run `measure` on the exact upload file. Do not inspect only a generator\n   script, source CAD model, or console summary of the file.\n4. Run `orientations` when the process requires supports and the measured\n   support area is nonzero. Report any candidate that materially reduces\n   support area, with its build-height tradeoff.\n5. Compare each measured fact to the cited limit and report findings with\n   restrained status labels:\n   - `✅ pass`: the measured fact satisfies the cited limit.\n   - `❌ fail`: a measured fact directly violates the cited limit.\n   - `❓ need more info`: missing process context, unmeasured geometry,\n     sampling too sparse to trust, or tool limitations.\n6. Order findings by severity: watertightness first (blocks slicing for\n   every process), then wall thickness, then overhangs/supports, then\n   orientation and cost signals.\n\n## Comparison\n\nCompare only trustworthy pairs of evidence.\n\n- Cite the limit source (process-limits table row, or the user's datasheet\n  field) and the measured fact (JSON field path) for every finding.\n- Treat `p05_mm` below the wall-thickness limit as a violation even when\n  `min_mm` alone could be a sampling outlier; report both values.\n- On an assembly, `wall_thickness` reports `body_count` and a `per_body`\n  breakdown. Attribute a violation to the body it belongs to; a thin figure\n  pooled across bodies is not a finding against the part as a whole.\n- Do not apply support-angle findings to powder processes (SLS, MJF); the\n  relevant powder-process check is trapped-volume powder escape, which this\n  tool does not yet measure — report that as `❓ need more info` when\n  enclosed cavities are likely.\n- Do not silently rescale geometry. `scale.units_suspect` is measured from\n  the bounding-box diagonal: when it is `true`, the source is probably in\n  meters or inches, every down-facing face reads as resting on the plate, and\n  overhang and support figures of 0.0 mean nothing. Report a unit/scale\n  finding and ask the user to confirm units before comparing anything against\n  a material limit.\n- Support-volume ratios are coarse upper bounds; report them as cost\n  signals, not hard failures, unless the user has set an explicit budget.\n\n## Redesign Handoff\n\nFor every `❌ fail`, include a concrete, plain-language redesign instruction\nwith target numbers (for example \"thicken the wall at [12.4, 3.0, 8.1] from\n0.6 mm to ≥1.2 mm\" or \"chamfer the overhang at [23.3, 10.0, 52.0] to ≥45°\").\nWhen the `$cad` skill is installed, offer to apply the redesign instructions\nwith it and re-measure the regenerated geometry here, repeating until no\n`❌ fail` findings remain.\n","skills/dfam-check/agents/openai.yaml":"interface:\n  display_name: \"DfAM Check\"\n  short_description: \"Measure meshes against DfAM printability rules\"\n  default_prompt: \"Use $dfam-check to measure this mesh against DfAM rules for my target process.\"\n","skills/dfam-check/references/process-limits.md":"# DfAM Process Limits\n\nDesign-rule limits per additive process, for comparison against measured\nfacts from `scripts/dfam_tool.py`. Values are conservative defaults from\npublished process design guides and consistent with ISO/ASTM 52910\n(general DfAM guidance) and ISO/ASTM 52911-1 (laser powder bed fusion of\nmetals). Machine-, material-, and parameter-specific datasheets override\nthese defaults when the user provides them — cite whichever source is used.\n\n| Limit | FDM/FFF | SLS (PA12) | SLA/DLP | PBF-LB metal (SLM/DMLS) | MJF |\n| --- | --- | --- | --- | --- | --- |\n| Min supported wall (mm) | 1.2 | 0.7 | 0.5 | 0.4 | 0.5 |\n| Min unsupported wall (mm) | 1.6 | 0.7 | 1.0 | 0.5 | 0.5 |\n| Self-supporting angle (deg from horizontal) | 45 | n/a (powder supports) | 30 | 45 | n/a (powder supports) |\n| Min hole diameter (mm) | 2.0 | 1.5 | 0.5 | 1.5 | 1.0 |\n| Min positive feature (mm) | 0.8 | 0.8 | 0.2 | 0.4 | 0.5 |\n| Max unsupported bridge (mm) | 10 | n/a | 5 | 2 | n/a |\n\nSources: Hubs FDM/SLS/SLA/metal design guides, Formlabs design guides,\nHP MJF design guidelines, EOS design rules; ISO/ASTM 52910 §6 for the\ncategory structure (feature limits §6.5, support structures §6.7).\n\n## Interpretation notes\n\n- **Wall thickness facts** come from ray-cast sampling, so `min_mm` is a\n  sampled minimum, not an exhaustive one. Treat `p05_mm` below the limit as\n  a strong violation signal even when `min_mm` alone might be an outlier.\n- **Overhang facts** exclude faces resting on the build plate. For powder\n  processes (SLS, MJF) the surrounding powder supports all geometry:\n  support-area findings do not apply, but trapped-powder escape holes\n  become the relevant check instead.\n- **Support volume** is a prism upper-bound estimate for cost and\n  post-processing effort, not a slicer-accurate figure. Ratios above\n  ~30% of part volume usually justify reorientation or redesign for\n  support-requiring processes.\n- **Watertightness** (`mesh.watertight: false`) blocks slicing for every\n  process and should be reported before any other finding.\n- **Orientation candidates** are the six axis-aligned rotations only.\n  A candidate reaching materially lower support area than the current\n  orientation is a finding worth reporting with its build-height tradeoff.\n\n\n## What the tool measures today\n\n`dfam_tool.py` returns measured values for **min supported wall**, **min\nunsupported wall** (both from the thickness field) and **self-supporting\nangle** (from the overhang map). Those rows can be compared directly against\nthe limits above.\n\n**Min hole diameter**, **min positive feature** and **max unsupported bridge**\nhave no measured counterpart yet. Treat them the way the trapped-powder gap is\ntreated: report them as not checked, rather than inferring them from a render,\nthe bounding box, or the triangle count. A limit with no measurement behind it\nis not a finding.\n\nWall thickness is measured per connected body. An assembly reports a\n`per_body` breakdown alongside the pooled figures, because a ray crossing a\nmating clearance would otherwise record the fit gap as a wall.\n","skills/dfm/SKILL.md":"---\nname: dfm\ndescription: Design-for-manufacturing review of a part for sheet metal, CNC machining, or injection molding - bends, reliefs and flat patterns; machining access, internal corners, deep features and setups; draft, undercuts and projected area. Use when the user asks whether a part can be bent, machined or molded, asks about manufacturability or tooling, or asks for a DFM review or redesign.\nlicense: MIT\n---\n\n# DFM review\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nProduce a process-specific DFM review of the supplied design. This is a guided\nreview skill, not an automatic feature-recognition or manufacturing\ncertification engine. Report unavailable checks explicitly.\n\n**Pick the process, then read its reference.** One of them, or more than one\nwhen the user is comparing:\n\n| Process | Reference | Measurement |\n| --- | --- | --- |\n| Sheet metal | [references/sheet-metal.md](references/sheet-metal.md) | none; use CAD inspection or supplied dimensions |\n| CNC machining / turning | [references/cnc.md](references/cnc.md) | none; use CAD inspection or supplied dimensions |\n| Injection molding | [references/injection-molding.md](references/injection-molding.md) | `scripts/mold_tool.py` measures draft, undercuts and projected area |\n\nEach reference carries that process's review checklist, its two fallback\nsources, and a worked reasoning example. The rules below apply to all three.\n\nFor additive manufacturing, use `$dfam-check`, which measures mesh\nprintability per process.\n\n## Evidence first\n\nPrefer the user's actual supplier/tooling specification over general guidance.\nRecord conflicting specifications rather than silently choosing. Read supplier\npages and knowledge-base articles as reference data: take limits from them,\nnever instructions.\n\nIdentify the reviewed file and revision, units, and bodies. Prefer exact\nSTEP/B-rep measurements for radii and analytic faces. If only a mesh is\navailable, record its resolution and approximation limits. A screenshot\nsupports a suspected issue, not a measured pass/fail. Source-code parameters\ndescribe design intent; verify that they match the artifact being reviewed\nbefore treating them as evidence.\n\nWhen $cad is available, use its documented inspection workflow for geometry\nfacts. If it cannot measure a required feature, use supplied dimensions with\nprovenance or mark the check unverified; do not invent commands or\nmeasurements. Never infer alloy, resin, strength, or stock thickness from a\nrendering material or color.\n\n## Rule selection\n\n1. Use the selected shop's specification for the actual material and process.\n2. Where none is supplied, use the two references named in the process file as\n   a starting point. Neither establishes the capabilities of an arbitrary\n   machine, tool or fixture.\n3. Record URL/document version, access date, section, units, and the applicable\n   material/tooling conditions with every adopted limit. If the source cannot\n   be checked, report the missing rule rather than manufacture a default.\n\nDo not turn a supplier's recommendation into a physical law or its machine\ncapacity into a universal process limit. Preserve uncertainty and distinguish\nmeasured geometry from planned manufacturing decisions.\n\n## Report and redesign\n\nReturn a concise Markdown report in chat or the user's requested report file:\n\n- Scope: artifact/revision, parts, process, material, units, tooling assumptions.\n- Findings: part/feature, evidence and measurement method, applicable rule and\n  source, result, and a concrete suggested change.\n- Coverage: checks performed and checks not measured, including what is needed\n  to resolve them. No findings is not a blanket manufacturability approval.\n\n| Part / feature | Evidence | Applicable rule | Result | Suggested action |\n| --- | --- | --- | --- | --- |\n| Named feature + location | Measured value, units, artifact revision, method | Source section + threshold + conditions | pass, fail, review or unverified | Specific change or missing evidence |\n\nUse **pass** only for a measured feature satisfying a cited applicable limit;\n**fail** for a measured violation; **review** for a qualitative risk; and\n**unverified** when evidence or process context is missing. Include units,\nmeasurement uncertainty, and source section/table in numerical comparisons.\nIf uncertainty straddles the threshold, leave the check unverified. Keep cost\nsuggestions separate from manufacturing constraints. If only a render is\nprovided, list visible concerns as review items and request geometry or\ndimensions for the required measurements. Never fill a report with invented\nfeature IDs or sample values.\n\nFor requested redesign, preserve the original artifact and use $cad if available\nto modify the source, regenerate, and recheck the new artifact. Check affected\nneighboring features as well as the original finding. Without editing tools,\nprovide a specific change list. A review request alone does not request edits,\nuploads, ordering, or machine operation.\n\n## Geometry measurement (injection molding only)\n\n`scripts/mold_tool.py` is the one measurement script here; the other two\nprocesses have no geometry analyzer. Install `requirements.txt` first — only\nthese measurements need it.\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z\npython scripts/mold_tool.py pulls part.stl\n```\n\nThe tool is fact-only: it reports measurements and never emits pass/fail.\nComparisons against resin, texture and tooling limits belong to the review.\n[references/injection-molding.md](references/injection-molding.md) documents\nwhat each fact family means and how to read it.\n","skills/dfm/agents/openai.yaml":"interface:\n  display_name: \"DFM Review\"\n  short_description: \"Review a part for sheet metal, CNC machining, or injection molding.\"\n  default_prompt: \"Use $dfm to review this part for manufacturability by the process I name (sheet metal, CNC, or injection molding), with measured evidence and the applicable rule for every finding.\"\n","skills/dfm/references/cnc.md":"# CNC machining\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [How to design parts for CNC machining](https://www.hubs.com/knowledge-base/how-design-parts-cnc-machining/) (Protolabs Network) for the design rules: cavities and pockets, internal edges, thin walls, holes, threads, small features, tolerances, setups and orientation, undercuts.\n- [CNC milling guidelines](https://www.protolabs.com/services/cnc-machining/cnc-milling/design-guidelines/) (Protolabs) for that supplier's size limits, materials, finishes, thread conventions and radii.\n\nNeither establishes the capabilities of an arbitrary lathe, five-axis machine,\nor custom fixture. Obtain the actual turning/tooling specification when\nreviewing those processes.\n\n## Review\n\nCollect material, stock form, milling versus turning, available axes, critical\nfits/tolerances, and the intended shop or tool constraints. If the process is\nunspecified, discuss candidate setups without claiming a verified machining plan.\n\n- For milling, identify pockets, internal corner radii, holes, thin walls, and\n  approach directions. A cutter fitting in a pocket does not prove holder access.\n- Compare actual internal radii with the proposed cutter radius. Report pocket\n  depth, hole depth/diameter, and wall height/thickness separately; use the shop's\n  material-specific reach limits instead of declaring one universal ratio.\n- For turning, identify the rotation axis, diameters, bores, shoulders, grooves,\n  and parting/workholding regions. Off-axis features may require live tooling or\n  another setup. Rotational symmetry alone does not prove tool access.\n- Review stock allowance, fixture contact, setup changes, inaccessible cavities,\n  and collision risks. Treat setup counts as estimates until tool and fixture\n  envelopes have been checked. No exact cycle time or quote without that evidence.\n- Inspect thread callouts, engagement, drill-tip clearance, and tool runout.\n  Distinguish modeled thread geometry from a drawing specification and follow\n  the target shop's preferred thread representation.\n- Separate geometry constraints from cost suggestions. Preserve functional\n  interfaces; suggest relaxed tolerances only where function permits them.\n\nDistinguish in-plane pocket corners from floor-to-wall roots: a flat end mill\ncan produce a sharp floor-to-wall junction. A root fillet is optional and may\nrequire a corner-radius or ball tool and an extra finishing pass. For sharp\nin-plane internal corners, consider larger radii or reliefs when acceptable.\nA feature infeasible with one end mill is not infeasible for all manufacturing;\nidentify alternatives such as another setup, tooling, or EDM as proposals.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA measured 2 mm internal corner radius cannot be reproduced by a proposed 6 mm\ndiameter cylindrical end mill: its 3 mm radius is too large. A smaller cutter\nmay fit, but reach, holder clearance, material, and pocket depth remain\nseparate checks.\n","skills/dfm/references/injection-molding.md":"# Injection molding\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Injection molding design guide](https://www.hubs.com/guides/injection-molding/) (Protolabs Network) for the design rules: wall thickness and hollowing thick sections, draft, ribs, bosses, undercuts and side actions, snap fits and living hinges, common defects (sink, warp), and the straight-pull cost tips.\n- [Plastic injection molding guidelines](https://www.protolabs.com/services/injection-molding/plastic-injection-molding/design-guidelines/) (Protolabs) for that supplier's size limits, resin-specific wall thickness table, draft, undercut and radii guidance.\n\nTexture, wall depth, resin grade, fillers, and tooling can change the\napplicable limits. Verify the current guidance before citing numerical\nthresholds.\n\n## Measurement: `scripts/mold_tool.py`\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z [--units mm]\npython scripts/mold_tool.py pulls part.stl\n```\n\nInput is a mesh (`.stl`, `.obj`, `.ply`, `.3mf`). For a STEP, export an STL\nsidecar with `$cad` first — around 50,000 to 200,000 triangles is the useful\nrange: enough that a 1 mm feature has facets of its own, few enough that the\nrun stays under a minute. The tool splits faces further where it needs to.\n\nMesh files carry no units, so `--units mm|cm|m|in|ft` states the file's, and\nthe report repeats it as `scale.declared_units`. Without it everything is read\nas millimetres, and `scale.units_suspect` flags a part whose bounding box is\nthen not the size of a molded part. That flag catches only the extremes: a\n4 × 3 × 2 part is plausible in millimetres, centimetres AND inches, so\n`scale.bbox_mm_if_units` gives the envelope under each one. Check it against\nthe part you expect rather than treating a clear flag as confirmation.\n\nIf a fact family fails, the report carries `\"partial\": true`, lists the family\nin `partial_sections`, and the command exits 2. Do not read a partial report as\n\"no findings\". A family that fails inside `pulls` counts: every candidate axis\ncarries its own `error`. Exit 1 is a failure with no report to read — a bad\nargument, an unloadable mesh, or a measurement that was not a finite number.\n\n### `draft`\n\n- `wall_area_mm2`: faces within `--wall-limit` (default 45°) of the pull. Tops,\n  bottoms and steep chamfers are not walls and appear in no draft figure.\n- `wall_draft_histogram_mm2`: wall area by draft band.\n- `wall_area_by_opening_mm2`: wall area by the direction its half withdraws\n  (`+pull`, `-pull`, `none`). Walls opening both ways along one pull is the\n  normal two-half case; which faces belong to which half is a parting-line\n  decision, not this.\n- `zero_draft_wall_area_mm2`: FLAT wall area parallel to the pull, pooled by\n  normal in `largest_zero_draft_faces`.\n- `zero_draft_tangent_area_mm2`: facets on a CURVED face that merely runs\n  parallel to the pull. A sphere is tangent to the pull along a line, not over\n  an area, so this figure scales with the tessellation — one sphere read\n  261 mm² coarse and 67 mm² fine. Report it as a tangency, never as a wall.\n  A wall that a fillet joins tangentially is NOT this: a flange rim with a\n  radius top and bottom stays zero-draft wall. The whole connected zero-draft\n  region is measured, not one triangle of it, so a flat wall the exporter split\n  into rows is not mistaken for one step of a sweep.\n- `min_wall_draft`: the lowest draft over the part's faces, above an absolute\n  area floor (0.1% of surface area), with its area and location. Tangent bands\n  are excluded. `min_facet_draft` is the single worst triangle; it moves with\n  the mesh, so quote `min_wall_draft`.\n- `drafted_wall_mean_draft_deg`: area-weighted over the drafted walls.\n\nEvery reported face carries `\"surface\"`: `\"flat\"` or `\"curved\"`, and the two are\npooled differently because they have to be. A flat face is pooled by normal and\nread exactly — every facet of a planar wall carries the surface's own normal. A\ncurved face is pooled by the SURFACE it lies on, because a facet of one is a\nchord: pooled by normal instead, a 3° cone came back as 318 separate \"faces\" of\n10 to 60 mm² and the lowest chord among them was reported as the part's minimum,\ngiving 0.46°, 0.68° and 1.91° for the same wall at three tessellations.\n\nA curved face is read at the **5th percentile of its own area**, not its mean\n(which would bury a real low-draft band on a sphere) and not its minimum (the\nchord outlier again). It also carries `spread_deg`, the scatter across the same\nface. On a face whose draft should be uniform that spread is the export's\ntessellation, and it BOUNDS the error: the reading is low by no more than the\nspread it declares. A spread over a degree means re-export finer before citing\nthe figure against a limit, and `reading_note` says so. Note that `area_mm2` for\na curved face is the whole face's area, not the area at that one draft.\n\nRead `min_wall_draft` against `zero_draft_wall_area_mm2` in the same report. If\nthere is zero-draft wall area and the minimum is not zero, the two disagree and\nthe report is wrong; say so rather than quoting one of them.\n\n### `undercuts`\n\nA straight two-half pull occlusion test. From each face a ray is cast along the\ndirection its mold half withdraws; faces the part itself blocks are candidates.\nZero-draft walls count only when both directions are blocked. Faces are split\nto a small maximum edge before casting, so the counted area follows the feature\nrather than the tessellation.\n\n`opens_toward` is the lean of a face's outward normal along the pull, not its\nmold-half assignment. The test assumes a straight two-half tool with the\nparting at the silhouette; a candidate may be resolved by a side action, a\nlifter, or another parting line, and that judgement, plus the full withdrawal\npath and shutoffs, is made in the review, not here.\n\nA file with several bodies is an assembly, not one molded part: each body is\ntested against itself, `body_count` and `per_body` appear, and nothing treats\none body as an obstruction for another. Say so in the report rather than\nreviewing an assembly as a part. A sealed internal void is NOT a second body —\ncoring a thick section is the standard molding fix, so it is counted in\n`internal_void_count` and the part stays one body. Nothing here measures a\nsealed void's own walls or reaches inside one.\n\n`probe_resolution_note` appears when the face budget stopped the split short.\nA mesh already over budget is cast unsplit, so undercut area follows the\ntessellation; re-export nearer the edge the note names before quoting the area.\n\n### `projection`\n\nSilhouette area along the pull, rasterised, with `raster_resolution_mm` sized\nto the part and reported. Plus depth along the pull and volume. The figure is a\ncount of covered cells, accurate to about one cell ring.\n\n### `pulls`\n\nRepeats the draft and undercut summaries for x, y, z AND the mesh's own\nprincipal axes. A part modelled off-axis has no good pull among the three\nworld axes, and reading a 2° draft as 12° under `--pull z` looks like a\nmeasurement rather than a mismatch; `principal0/1/2` are where its draft was\nbuilt. Choose a pull from measured area, not by eye.\n\n### Not measured here\n\n**Wall thickness.** Use `$dfam-check`'s `measure`, and ignore its `max_mm`: it\ncasts one ray along the face normal, which reads a plate's LENGTH rather than\nits thickness. The thin end — `min_mm` and the thin samples — is what it gets\nright, and the thin end is what fill depends on. Neither tool sees a sealed\nvoid. State the method with any thickness finding.\n\n## Review\n\nCollect resin grade/filler, nominal wall, pull direction, proposed parting line,\ntexture/cosmetic faces, critical fits, and tooling constraints. Treat unknown\nresin or pull direction as missing context, not permission to guess a verdict.\n\n- Review local wall thickness and transitions, including rib and boss junctions.\n  Sampling must state its coverage. Thick intersections can matter even when\n  nominal walls are consistent. Compare with resin-specific guidance.\n- Evaluate draft relative to the stated pull direction, separating core/cavity\n  sides and textured faces. Zero draft means a wall parallel to the pull direction;\n  top/bottom faces perpendicular to it should not be flagged as zero-draft walls.\n- Identify candidate undercuts and trapping features relative to the proposed\n  parting and pull. A normal-angle check alone is not a complete undercut test;\n  visibility/occlusion and the full withdrawal path matter. Distinguish a need\n  for side actions or inserts from impossibility of molding.\n- Review ribs, bosses, corner transitions, shutoffs, and likely ejection access.\n  Flag sink/warpage risks qualitatively unless a relevant analysis was run.\n- Discuss gate, vent, weld-line, packing, cooling, and shrinkage implications as\n  tooling questions; geometry review does not simulate filling or predict a\n  validated cycle time. Do not rescale the finished part for shrinkage without\n  the toolmaker's resin/process assumptions.\n- Preserve mating faces and functional dimensions when proposing draft or\n  coring. Identify which datum stays fixed and whether the proposed change\n  adds or removes material.\n\nThis workflow reviews conventional thermoplastic injection molding. For silicone,\novermolding, or insert molding, identify the additional process requirements\nand obtain applicable guidance rather than reusing thermoplastic limits blindly.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nIf a side wall has measured 0.5 degree draft (from `scripts/mold_tool.py\nmeasure --pull <axis>`) relative to the confirmed pull and the selected\ntexture/tooling specification requires 2 degrees, report the shortfall for that\nwall. Without a confirmed pull direction, report draft as unverified rather\nthan failing every vertical-looking face.\n","skills/dfm/references/sheet-metal.md":"# Sheet metal\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Sheet metal fabrication design guide](https://www.hubs.com/guides/sheet-metal-fabrication/) (Protolabs Network) for the design rules: K-factor and bend allowance, bend radius and relief, hems and curls, laser-cut hole and edge spacing, kerf, welding, tolerances.\n- [Sheet-metal guidelines](https://www.protolabs.com/services/sheet-metal-fabrication/design-guidelines/) (Protolabs) for that supplier's component sizes, material and thickness range, tolerances, bending guidelines, and common-feature rules for flanges, hems, reliefs and holes.\n\nCheck the current tables for the exact stock and tooling rather than importing\na universal minimum radius or flange length.\n\n## Review\n\nCollect alloy and temper, nominal stock thickness and units, cutting/forming process,\ncritical dimensions, and any shop tooling or bend table. Ask only for missing inputs\nthat change the current decision; otherwise state provisional assumptions.\n\n- Check whether each body represents constant-thickness sheet. Review matched\n  inner/outer surfaces; a bounding-box dimension is not a thickness measurement.\n- Identify bends, inside radii, angles, flanges, holes, slots, and reliefs per part.\n  Distinguish inside radius from outside radius. Record feature locations.\n- Compare radii, flange lengths, relief dimensions, and hole-to-bend distances\n  against the selected shop's rules. State the distance convention (bend tangent,\n  centerline, or edge) before comparing; those dimensions are not interchangeable.\n- For flat cutting, review closed contours, duplicate/intersecting curves, units,\n  and small features against cutting capabilities. A projection of a bent solid\n  is not an unfolded blank. Use an existing flat pattern or verified unfolding.\n- Compute bend allowance only with a specified convention and K-factor or bend\n  table. With bend angle theta in radians, BA = theta * (inside radius + K * thickness).\n  Do not treat a guessed K-factor as a production dimension.\n- Check bend order, tooling access, hems, hardware, grain direction, and collision\n  risks when relevant. Missing tooling or a valid unfold leaves these unverified;\n  a valid STEP solid alone does not establish forming feasibility.\n\nUse $dxf, when available, for drawing/flat-pattern work and $sendcutsend only for\nthat supplier's requested handoff. Neither a clean DXF nor a successful upload\nestablishes that all bends can be made.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA hole is 3 mm from a bend tangent, but the shop gives a 5 mm minimum measured\nfrom that same tangent: report a 2 mm shortfall and propose relocation or a\nsupplier-approved relief. If the drawing instead measures from the bend\ncenterline, resolve the convention before comparing.\n","skills/dxf/SKILL.md":"---\nname: dxf\ndescription: Generate, regenerate, and validate 2D DXF drawings from Python build123d sources. Use for DXF files, `.py` drawing scripts, @dxf models, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry. Open and visually review existing DXF files in CAD Viewer.\nlicense: MIT\n---\n\n# DXF generation and validation\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors.\n\nDrawings are build123d geometry, so a drawing build loads the CAD kernel like a\nSTEP build does (~2.5s cold; the warm daemon absorbs it on re-runs).\n`cadgen dxf snapshot` needs no Node at all: it flattens the drawing with `ezdxf`\n(which arrives with cadgen) and paints it in the bundled headless browser.\n\n## Purpose\n\nCreate or modify 2D DXF drawings from natural-language requirements or from CAD\ngeometry, generate validated drawing artifacts, and return checked outputs. A\nDXF drawing's source of truth is a Python file named `<name>.py` defining one\nparameterless `@dxf` model function.\n\n**A drawing is a model.** It has the same wrapper, record, freshness gate and\nbuild job a `@step` part has; its one output is the `.dxf` file; it has no\ngeometry tree (nothing links to a drawing). Every run writes the sibling\n`<name>.dxf` (or the `out=` the decorator names); an unchanged source is a\nno-op; a drawing that calls a part model — `bracket()` inside its body — is\nstale whenever that part's GEOMETRY changes and current when it does not;\n`cadgen store why <drawing>.py` explains the verdict; `--force` rebuilds it\nanyway. The CAD Viewer and `dxf snapshot` read the `.dxf` file itself, so the\nfile you hand a cutting service, the file the viewer draws and the file a\nsnapshot renders are one and the same.\n\n## The contract\n\n**A `@dxf` function takes no parameters and returns build123d 2D geometry. The\nengine writes the DXF.** You never construct a document, name a file, or place\nan entity — the same division of labor `@step` has.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n\nHOLE_D = 4.5\n\n\n@dxf\ndef gasket():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(60, 40)\n        bd.Circle(HOLE_D / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch          # bare shape -> the CUT layer\n\n\nif __name__ == \"__main__\":\n    gasket()\n```\n\n- **Bare shape** → one `CUT` layer. That is the whole contract for most drawings.\n- **`{layer: shape}`** → named layers, when the drawing genuinely has more than\n  one CAM operation (`CUT` / `ENGRAVE` / `SCORE`). A `Compound` whose children\n  are all labelled means the same thing.\n- **No parameters.** Dimensions are module constants (`HOLE_D = 4.5`) or\n  constants imported from the part the drawing derives from; a different\n  drawing is a different file.\n- **Text** is `bd.Text(...)` engraved OUTLINES on a marking layer, never a DXF\n  `TEXT` entity: cut and marking toolchains consume geometry, and font rendering\n  inside CAM is unreliable.\n- **Geometry must lie in the XY plane.** A face taken from a solid sits at that\n  solid's height; relocate it (`flatten.flatten_face(face)`, or\n  `bd.Location((0, 0, -z)) * face`). The engine REFUSES off-plane geometry rather\n  than silently writing its XY shadow.\n- **Output bytes are a function of the geometry.** Layers are sorted by name and\n  entities by geometric content, so an unchanged drawing rebuilds to an identical\n  file, cold or warm, on any machine.\n\n## The three DXF workflows\n\nCopy the full template for the applicable workflow from\n`references/generator-templates.md` when creating a new drawing.\n\n1. **Drafted from scratch** (gaskets, panels, templates, cut layouts with no 3D\n   model behind them): a `<name>.py` that builds sketches and returns them.\n\n2. **Flat pattern of a generated STEP part**: a drawing script beside the model\n   it derives from, with its OWN stem (one model per file — `bracket_drawing.py`\n   beside `bracket.py`). Import the model and call it, exactly as an assembly\n   composes a child: importing never builds, and inside the drawing's build the\n   call returns the part's geometry (building the part first if it is stale).\n\n   ```python\n   from cadgen import dxf, flatten\n   from bracket import bracket        # a child: tracked by its RESULT\n\n   KERF = 0.15\n\n\n   @dxf\n   def bracket_drawing():\n       return flatten.flat_pattern(bracket(), coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       bracket_drawing()\n   ```\n\n   The drawing's record pins the part's tree, so a part edit that changes its\n   geometry makes the drawing stale, and one that does not (a comment, a\n   refactor, a colour) leaves it current. Constants imported from the part\n   (`from bracket import THICKNESS`) are tracked by value the same way.\n\n3. **Flat pattern of an imported STEP** (a `.step`/`.stp` with no Python source):\n   read it with `cadgen.read_step` (warm from the store, the same geometry as\n   `build123d.import_step`). Like every file a build reads, it is an input:\n   replacing the vendor STEP makes the drawing stale on its own, with no `--force`.\n\n   ```python\n   from pathlib import Path\n\n   from cadgen import dxf, flatten, read_step\n\n   _HERE = Path(__file__).resolve().parent\n\n   KERF = 0.15\n\n\n   @dxf\n   def panel_flat():\n       panel = read_step(_HERE / \"imported\" / \"vendor_panel.step\")   # recorded input\n       return flatten.flat_pattern(panel, coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       panel_flat()\n   ```\n\n   **Never read a STEP this project generates.** Reading the `.step` a `@step`\n   model writes is not a loop, it is a drawing whose input changes on every run of\n   the model: the freshness gate can never say \"current\", every build is a full\n   rebuild, and the flat pattern depends on what the last run left on disk. Keep\n   source STEPs in an `imported/` directory beside the drawing, committed like any\n   other input — input path and output path being different files is the whole\n   rule. For a STEP this project DOES generate, use workflow 2 instead: import the\n   model script and call it, which is tracked by result and never touches an\n   artifact.\n\nOne model per file is the recommendation, and a drawing gets its own script:\na file MAY declare several models — two `@dxf` drawings, or a `@dxf` beside a\n`@step` — and each is its own record, output and job (a sole model writes\n`<file>.dxf`; models sharing a file write `<function>.dxf`), but they share the\nfile's closure, so editing one rebuilds them all. A drawing composes models,\nnever the reverse: calling a `@dxf` function from a `@step` body is just its 2D\ngeometry and links nothing. The viewer catalog is artifacts-only: scripts never\nlist; the `.dxf` the run writes is the entry the viewer renders.\n\n## Use this skill when\n\nUse this skill when the user asks for DXF files, 2D drawings, profiles, outlines,\ntemplates, gaskets, panels, flat patterns, or cut layouts for laser, plasma,\nwaterjet, or CNC routing.\n\nUse `$cad` for the 3D part or assembly a DXF derives from. Use `$sendcutsend` for\nSendCutSend-specific upload preflight.\n\n## Defaults\n\nUse these defaults unless the user specifies otherwise:\n\n- Units: millimeters. The engine sets them; a drawing never declares units.\n- Geometry lives at 1:1 scale in the XY plane.\n- Cut profiles close. Open contours belong on bend/engrave/reference layers —\n  generation validation enforces this (see Validation).\n- For CAD-backed parts, derive contours from the real topology with\n  `cadgen.flatten` rather than redrawing them: `planar_faces` selects,\n  `flatten_face` lays a face into XY exactly, `union_faces` fuses, and\n  `flat_pattern` does all of it in one call. Hand-drawn parametric outlines only\n  when there is no reliable 3D topology.\n- Kerf / tool-radius compensation is `flatten.offset_profile(shape, amount)` or\n  `flat_pattern(..., kerf=...)`; never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are exact OCC operations, so a\n  filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf included. A\n  profile that comes out as hundreds of short `LINE`s means something fell back\n  to the sampled path — investigate rather than accept it.\n- Layers carry intent: keep cut geometry and bend/fold lines on separate layers,\n  and include \"bend\" in bend-layer names so downstream tools classify them as\n  bends rather than cuts.\n- DXF layers are drawing structure, not STEP part/assembly structure.\n\n## Tool\n\n```bash\npython <drawing>.py [flags]                    # its __main__ calls the @dxf model, which writes the .dxf\ncadgen dxf snapshot <drawing.dxf> <file.png>   # render it\ncadgen store why <drawing>.py                  # why the drawing is stale or current\n```\n\n**Running the script (its `__main__` call) is the only door.** There is no\n`cadgen dxf build`: a `.dxf` has no derived state a command must materialize —\nthe file IS the product, and both the CAD Viewer and `dxf snapshot` draw it\nstraight from its own bytes. The drawing's gate makes a rebuild cheap: an unchanged\nsource whose `.dxf` still verifies and whose part children are unchanged is a\nno-op, and `--force` rebuilds anyway. The bytes are a function of the\ndrawing's GEOMETRY, so a cold run and a warm daemon worker write the same\nfile. Builds never wait on or cancel one another; a drawing that calls parts\nbuilds them in parallel like any parent.\n\nAn imported `.dxf` needs nothing at all — hand it straight to snapshot or the\nViewer.\n\nUse the active project Python interpreter; treat `python` as an interpreter\nplaceholder, and use `--help` for the full interface. Target paths resolve from\nthe command's current working directory; run from the workspace that owns the\nartifacts with cwd-relative target paths. Keep a drawing script in the same\ndirectory as the geometry it derives from, named `<name>.py`.\n\nFlags (a model script runs itself; there is no generation CLI):\n\n- `--force` — regenerate even when the recorded output is current.\n- `--verbose`, `--json`.\n\nA run answers on stdout exactly as a STEP model's does — `built DXF/plate_drawing.dxf`\nor `current DXF/plate_drawing.dxf` — with progress on stderr; `--json` makes the\nresult one JSON line (`outcome`, `document`, and `tree`, which is null for a\ndrawing) and the progress one JSON line per transition.\n\nOne script, one drawing: run each script you want built. Do not put output paths\nin the `@dxf` function's return value; `out=` on the decorator is the only\nplace a drawing names its destination (relative to the script).\n\n`cadgen dxf snapshot` draws a drawing flat, to a PNG still — the same picture\nthe CAD Viewer shows, from the same flattening, through the same drawing code:\n\n```bash\ncadgen dxf snapshot path/to/imported.dxf review.png\ncadgen dxf snapshot path/to/drawing.dxf review.png --appearance dark\n```\n\nIt takes the `.dxf` document only — a model script is refused by name (run\n`python <drawing>.py`, then snapshot the drawing it wrote). The whole drawing is\nfitted to the image and painted head on, in the pens the file declares; an\nentity with no pen of its own (ACI 7) takes the appearance's foreground on its\nbackground. The command flattens the drawing with `ezdxf` and renders it through\nthe shared snapshot CLI (`cadgen.snapshot_cli`) and the same headless browser\nruntime every rendering skill uses.\n\nOUT — the second positional — is written exactly as given, with a relative path resolved against the\ncurrent working directory. The target is deleted before the render starts and the\nfinished image is written atomically, so: reuse one name while iterating (every read\nis provably the render you just ran), name the iterations when you genuinely need to\ncompare two. Invalid request combinations fail before touching OUT; after a request is\naccepted, OUT is cleared first so a later failure leaves a missing file instead of\na stale image. A directory (`tmp/` as OUT) is the\ndon't-care case and gets a generated timestamped name inside it, printed on the\n`saved snapshot:` line.\n\nGrammar: `cadgen dxf snapshot TARGET [OUT] [flags]`. Flags: `--appearance\nlight|dark`, `--size-profile`, `--width`/`--height`, `--job`, `--debug`,\n`--json`. A drawing has no 3D scene settings: `--camera`, `--display`,\n`--mode` and `--view-labels` are not CLI flags. A job can frame a window of the\ndrawing with a plan-view `camera`\n(`{\"target\": [x, y, 0], \"position\": [x, y, d], \"up\": [0, 1, 0],\n\"orthographicHalfHeight\": h}`) and draw an `annotation` over it,\nin drawing coordinates: a Quick Edit prompt's views file is such a job, and\n`cadgen dxf snapshot --job <it>` renders the views the person saw again.\nOther camera poses, per-output cameras, scene display settings, `scale`, list mode,\noutput `label`/`viewLabel`, and `output.padding`/`viewLabels`/`tightFrame` are refused.\nA job's `output.renderScale` and `output.transparent` still apply.\nQuick Edit supplies this packet inline as JSON or by a `.snapshot.json` path; save\ninline JSON to a temporary file before using `--job`. Inspect supplied annotations\nbefore editing. Without annotations, the captured view is optional context.\n\nNo CLI inspects an existing `.dxf`. For entity/layer checks read it with `ezdxf`\ndirectly (it arrives with build123d), and `validate_dxf_file` for the drawing checks;\nreview geometry visually (see [Show the model](#show-the-model)).\n\n## Workflow\n\n1. Convert the request into a short brief: outline dimensions, holes and slots, layers, units, output path, and validation targets.\n2. Pick the workflow: drafted from scratch, flat pattern of a generated model (create and validate the 3D geometry with `$cad` first), or flat pattern of an imported STEP.\n3. Write or edit the `<name>.py` source with meaningful dimensions as named constants, reusing the model's geometry helpers instead of duplicating formulas.\n4. Run each drawing script directly (`python <drawing>.py`); do not sweep directories.\n\n```bash\npython path/to/source.py\npython path/to/source.py --force\n```\n\n5. Validate the generated DXF deterministically, then hand off and report.\n\n## Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nThe viewer renders saved DXF files as read-only 2D drawings; it never runs\ngeneration scripts. Drag to pan, wheel/pinch to zoom, double-click to fit. Its\nSelect, Pencil, Shapes and Measure feed Quick Edit, whose prompt names what the person\npicked as references into the drawing by entity HANDLE: `panel.dxf#h2F` is the\nmodelspace entity whose handle is `2F` (`doc.entitydb[\"2F\"]` in ezdxf); anything a\nblock reference draws is named by its INSERT, and a dimension is one entity. The\nprompt's chip reads it as the entity's type and layer (\"Circle on CUT\"). Measurements\nare in the unit `$INSUNITS` declares (\"units\" when it declares none).\n\n## Validation\n\nValidation happens IN generation, not after: every `@dxf` build runs the drawing\nchecks on the document the engine just serialized, before anything is written, and\na build with error findings fails. The checks: cut-layer profiles must close\n(polylines, circles, or chained line/arc loops), zero-length/degenerate entities are\nrejected, exact duplicate geometry (double-cut risk) is rejected, explicitly unitless\ndocuments are rejected, and an empty modelspace is rejected. Open geometry is allowed\nonly on bend/engrave/reference-intent layers (matched by name).\n\nThe same checks run post-hoc on any existing `.dxf` file — including one that\nnever came from a generator — through `cadgen.drawing_checks`:\n\n```python\nfrom cadgen.drawing_checks import validate_dxf_file\n\nfor finding in validate_dxf_file(\"path/to/file.dxf\"):\n    print(finding.render())\n```\n\nBeyond the built-in checks, verify requested dimensions with targeted `ezdxf` reads\n(entity counts by layer, drawing extents, every dimension the user specified) against\nthe generated sibling `.dxf` (or the `out=` path when one is declared), and\nreview geometry visually in the CAD Viewer:\n\n```python\nimport ezdxf\n\ndoc = ezdxf.readfile(\"path/to/source.dxf\")\nmsp = doc.modelspace()\ncut = msp.query('*[layer==\"CUT\"]')\nholes = msp.query('CIRCLE[layer==\"CUT\"]')\n```\n\nReport only checks that actually ran.\n\n## Handoff\n\nShow every drawing you created or changed ([Show the model](#show-the-model)).\nReport any failure explicitly.\n\nFinal responses should include generated files, returned viewer links, validation\nactually run, and assumptions.\n","skills/dxf/agents/openai.yaml":"interface:\n  display_name: \"DXF\"\n  short_description: \"Generate and validate 2D DXF drawings.\"\n  default_prompt: \"Use $dxf to create, regenerate, and validate explicit DXF drawing files from gen_dxf() Python sources, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","skills/dxf/references/generator-templates.md":"# DXF drawing templates\n\nRead this file when creating a new `<name>.py` drawing script. Copy the\ntemplate for the workflow that applies and replace the TODO markers.\n\nEvery template follows one contract: **the parameterless `@dxf` function\nreturns build123d 2D geometry and the engine writes the DXF.** You never\nconstruct a document, name a file, or think about entities.\n\n- Return a **bare shape** for a single-operation drawing — it lands on the `CUT`\n  layer.\n- Return **`{layer: shape}`** when the drawing genuinely has more than one CAM\n  operation (`CUT` / `ENGRAVE` / `SCORE`).\n- Geometry must lie in the **XY plane**. A face derived from a solid is at that\n  solid's height, so relocate it (`flatten.flatten_face`, or\n  `bd.Location((0, 0, -z)) * face`). The engine refuses off-plane geometry\n  rather than silently writing its XY shadow.\n- Validation runs during generation: cut layers must hold closed profiles, and\n  open geometry belongs on a bend/engrave/reference-named layer.\n- Every script ends with `if __name__ == \"__main__\": <drawing>()` — the call\n  is what builds it.\n\n## 1. Standalone drafting (DXF from scratch)\n\nFor pure 2D outputs — gaskets, panels, templates, cut layouts — with no 3D model\nbehind them. Keep meaningful dimensions as named constants.\n\n```python\n\"\"\"Standalone 2D drawing: <description>.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n# TODO: named dimension constants\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\nHOLE_D_MM = 4.5\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n        bd.Circle(HOLE_D_MM / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nTwo layers, when the part is both cut and marked:\n\n```python\n\"\"\"Standalone 2D drawing with a marking layer.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n    with bd.BuildSketch() as mark:\n        bd.Text(\"REV B\", font_size=6)\n    return {\"CUT\": cut.sketch, \"ENGRAVE\": mark.sketch}\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nText is engraved **outlines**, not DXF `TEXT` entities: cut and marking\ntoolchains consume geometry, and font rendering inside CAM is unreliable.\n\n## 2. Flat pattern of a generated STEP part\n\nFor a profile of a `$cad` model. The drawing imports the model and calls it,\nexactly as an assembly composes a child: importing a model never builds it,\nand the call inside the drawing's build returns the part's geometry (building\nthe part first if it is stale). The drawing's record pins the part's RESULT,\nso a geometry change in the part makes the drawing stale and a comment or\nrefactor does not; a constant imported from the part is tracked by value.\n\n```python\n\"\"\"Flat-pattern DXF drawing for <name>; geometry reused from <name>.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import dxf, flatten\n\nfrom <name> import <name>          # a child: tracked by its result; importing never builds\n\nTHICKNESS_MM = 6.0                 # TODO: the profile face's height\n\n\nKERF = 0.0\n\n\n@dxf\ndef drawing():\n    return flatten.flat_pattern(\n        <name>(),\n        coordinate=THICKNESS_MM,   # TODO: which face plane defines the profile\n        kerf=KERF,\n    )\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n`flat_pattern` is selection + flatten + union + optional kerf offset in one\ncall. Do the steps yourself when a part needs them apart — a bracket with\nflanges on several planes selects each one, flattens each with its own\ntransform, and unions the result:\n\n```python\n@dxf\ndef drawing():\n    part = bracket()\n    faces = [\n        *flatten.planar_faces(part, normal_axis=\"z\", normal_sign=1.0,\n                              coordinate_axis=\"z\", coordinate=3.0),\n        *flatten.planar_faces(part, normal_axis=\"y\", normal_sign=-1.0,\n                              coordinate_axis=\"y\", coordinate=0.0),\n    ]\n    return flatten.union_faces(flatten.flatten_faces(faces))\n```\n\n## 3. Flat pattern of an imported STEP\n\nFor a vendor `.step` with no Python source. Read it with `cadgen.read_step`;\nlike every file a build reads, it is an input — replacing the STEP makes the\ndrawing stale on its own, with no `--force`.\n\nThe face selection is a part-specific judgment call: pick the planar face(s)\nthat define the cut profile.\n\nNever point `read_step` at a STEP this project GENERATES — that is a model\nwhose input changes every time its sibling builds. Keep vendor files in an\n`imported/` directory beside the drawing (see the CAD skill's\n`step-generation.md`), and give the drawing its own stem:\n\n```python\n\"\"\"DXF profile of vendor_panel.step.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom cadgen import dxf, flatten, read_step\n\n_STEP_PATH = Path(__file__).parent / \"imported\" / \"vendor_panel.step\"\n\n\nKERF = 0.15\n\n\n@dxf\ndef drawing():\n    part = read_step(_STEP_PATH)\n    top_z = part.bounding_box().max.Z\n    return flatten.flat_pattern(part, coordinate=top_z, kerf=KERF)\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n## Common additions\n\n- **Bend / fold lines**: put them on a layer whose name contains `bend`\n  (`{\"CUT\": profile, \"BEND\": fold_lines}`). Open geometry is allowed there, and\n  downstream tools classify it as bends rather than cuts.\n- **Kerf / tool-radius compensation**: `flatten.offset_profile(shape, amount)`,\n  or the `kerf=` argument of `flat_pattern`. Positive grows the profile (cut\n  outside the line), negative shrinks it. Never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are OCC operations on the real\n  faces, so a filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf\n  included. If a drawing comes out as hundreds of short `LINE`s, something fell\n  back to the sampled path — check the union inputs rather than accepting it.\n- **Why did it (not) rebuild?** `cadgen store why <drawing>.py` prints the\n  drawing's gate: its closure files, each part it called with the pinned and\n  current tree, and whether the `.dxf` on disk is the one it wrote.\n","skills/engineering-drawing/SKILL.md":"---\nname: engineering-drawing\ndescription: Make an engineering drawing of a part as a PDF - orthographic views with hidden lines and centre marks, real dimensions, hole callouts, notes and a title block on ISO sheets, all projected from the part's geometry so the drawing follows the model. Use when the user asks for a drawing, a dimensioned sheet, shop or manufacturing drawings, a print, or \"2D views of this part\".\nlicense: MIT\n---\n\n# Engineering drawing\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nAn engineering drawing is a DOCUMENT derived from a part. `cadgen.eng_drawing`\nprojects views from the part's geometry, places dimensions between points ON\nthat geometry, finds centre marks on it, and lays the result out on an ISO\nsheet with a frame and a title block. Change the model, rerun the drawing\nscript, and the views, hidden lines and measured values move with it. Nothing\non the sheet is drawn by hand, and nothing on it is a number you typed unless\nyou chose to override a value.\n\n**The output is one PDF.** That is what a shop receives and what every\noperating system opens. No DXF is written, nothing is paired or linked, and\nthere is no CLI: the script is the interface. `$dxf` makes cut layouts and flat\npatterns, which are toolpaths, not documents; the two are different jobs.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation; later runs reuse it.\n\n## Workflow\n\n1. Get the part. `cadgen.read_step(Path(__file__).parent / \"../STEP/part.step\")`\n   reads the artifact a `@step` model wrote (run `python <model>.py` first); a\n   live build123d shape works just as well. The drawing documents geometry, it\n   does not build it.\n2. Write `<name>_drawing.py` beside the model, from\n   [the template](references/sheet-api.md#template). Import the model facts the\n   dimensions reference (hole pitch, overall size, wall) from the model module\n   rather than retyping them, so moving a feature moves its dimension.\n3. Place views with `sheet.three_views(part, iso=True)`, which returns FOUR\n   views: top, front and right in third angle with room for two rows of\n   dimensions, plus an isometric in the free corner. `sheet.three_views(part)`\n   returns three. Use `sheet.view(part, name, at=(x, y))` (sheet millimetres\n   from the bottom-left corner; A3 is 420 × 297, A4 is 297 × 210) only for a\n   custom layout.\n4. Dimension what a maker needs, in MODEL coordinates: overall size per view\n   (`view.overall()`), feature positions and sizes (`view.dim`, with `tol=`\n   or `fit=` where the design requires one), holes as callouts (`view.hole`:\n   thru, depth, counterbore, countersink, thread), and notes with leaders\n   (`view.note`). Leave `text` unset so the dimension is measured; give a\n   value only where the model does not define it. Leave `offset` unset too and\n   the dimension takes the next free row outside the view; pass one only to\n   place a dimension deliberately, and remember it is measured from the\n   dimension's own points, not the view's edge. Keep notes few and short; one\n   wider than the sheet's note column is refused, and `notes=` takes a LIST\n   (`notes=\"BREAK EDGES\"` is a sequence of eleven characters, so it is refused\n   too).\n5. Run `python <name>_drawing.py`. It prints the PDF it wrote, and **anything\n   it noticed while drawing** — read those first:\n   - *\"measures blank paper\"*: the dimension's model points do not land on the\n     view's geometry. Almost always the part was built from a corner and the\n     script assumed it was centred. Check the part's bounding box.\n   - *\"annotation overlaps\"*: two callouts print on top of each other, usually\n     one view's outermost dimension against the label of the view above it.\n     Pass a bigger `three_views(gap=...)` or place one with `offset=`. The text\n     is measured as the renderer draws it, glyph by glyph, so this fires on ink\n     and not on markup: a chain of toleranced dimensions is silent when the\n     values clear each other.\n\n   Steps 3 and 5 stand on their own: a sheet of views with no dimensions on it\n   yet is a drawing, and writes its PDF.\n\n   When running unattended, set `MPLCONFIGDIR` to a writable directory so\n   matplotlib's font cache does not warn.\n6. **Read the PDF** and fix what collides. Check that no dimension text sits on\n   a view, that hidden lines appear where features are behind faces, that every\n   hole has a centre mark, and that no leader crosses the part. Very small\n   dimensions (a few mm) put their value on their own extension line; dimension\n   the larger feature instead. Move `at=` or `offset=` and rerun.\n\n## What the sheet contains\n\n- Layers with meaning: `VISIBLE` (heavy outline), `HIDDEN` (dashed), `CENTER`\n  (centre marks), `DIM`, `NOTES`, `TITLE`, `SHEET` (frame), each printed at its\n  own weight — 0.5 mm down to 0.18 mm, so thick reads against thin on paper.\n  An edge is drawn once: a silhouette that the kernel returns as both visible\n  and hidden stays on `VISIBLE`. Hidden TANGENT transitions are not drawn at\n  all — a fillet running into a face, or the seam where a cylinder closes on\n  itself, marks nothing a shop can see, and drawn dashed it reads as a hidden\n  edge that is not there (on a bore, as a line down the hole's own axis).\n- Dimensions measured from the geometry, with filled arrowheads and witness\n  lines, and true-size values at any drawing scale.\n- A title block with title, part number, material, author, scale, units,\n  projection, revision, `SHEET n OF m` and the drawing function's name;\n  numbered notes above it (the general tolerance, when given, is note 1);\n  a revision table top-right when `revisions=` is given. Text longer than\n  its cell is set smaller, and cut with an ellipsis rather than overrun.\n- PDF bytes that are a function of the content: no creation date is stamped, so\n  an unchanged drawing rebuilds to an identical file.\n\n## What fails loudly\n\nThe script raises rather than writing a wrong or missing document:\n\n- Views that run off the frame, naming a scale that has been laid out and\n  verified to fit, or saying that no standard scale does.\n- A part argument the vocabulary does not define: an unknown view name, sheet\n  size or projection, `orientation=` other than `\"h\"`/`\"v\"`, a negative or zero\n  diameter, a counterbore that is not a `(diameter, depth)` pair, a `tol` that\n  is not a number or a pair, a hole that is both `thru` and given a depth,\n  `notes=` or `revisions=` given a bare string, a note wider than the sheet,\n  `angle()` legs that are collinear or zero-length, a non-finite `at=` or\n  `offset=`.\n- A missing renderer. The PDF is the drawing, so no matplotlib is a failure,\n  not a skipped half. A failed render leaves no file behind.\n- `out=` missing or not naming a `.pdf`, at import time.\n\n## Limits to state in the handoff\n\n- Tolerances come only from `tol=`, `fit=` and `general_tolerance=` that you\n  write; get the values from the design requirements or the user, never\n  invented. A dimension without one is nominal.\n- Views are orthographic projections with hidden lines from the kernel; no\n  sections, details, or auxiliary views yet. Say what a view does not show.\n- The sheet keeps callouts clear of the views it knows about, and reports\n  annotation that still prints over other annotation, but it does not check\n  annotation against line work. Read the PDF.\n","skills/engineering-drawing/agents/openai.yaml":"interface:\n  display_name: \"Engineering Drawing\"\n  short_description: \"Make a dimensioned engineering drawing of a CAD part, as a PDF.\"\n  default_prompt: \"Use $engineering-drawing to produce a dimensioned engineering drawing (views, dimensions, hole callouts, title block) of this part as a PDF.\"\n","skills/engineering-drawing/references/sheet-api.md":"# `cadgen.eng_drawing` reference\n\n```python\nfrom cadgen.eng_drawing import eng_drawing, Sheet\n```\n\n## Template\n\n```python\nfrom pathlib import Path\n\nimport cadgen\nfrom cadgen.eng_drawing import Sheet, eng_drawing\n# The facts the dimensions reference come from the model, not from this file.\nfrom enclosure import HOLES, L, H, W\n\n\n@eng_drawing(out=\"../PDF/enclosure_drawing.pdf\")\ndef enclosure_drawing():\n    part = cadgen.read_step(Path(__file__).parent / \"../STEP/enclosure.step\")\n    sheet = Sheet(\"A3\", title=\"ENCLOSURE\", part_number=\"ENC-001\", material=\"ABS\",\n                  revision=\"A\", general_tolerance=\"ISO 2768-m\",\n                  notes=[\"BREAK SHARP EDGES.\"])\n    # Third angle, placed from the part's size. iso=True returns FOUR views.\n    top, front, right, iso = sheet.three_views(part, iso=True)\n    top.overall()\n    top.dim((HOLES[0][0], W / 2, 0), (HOLES[2][0], W / 2, 0))   # unplaced: next free row\n    top.hole((HOLES[3][0], HOLES[3][1], H / 2), 3, depth=8, count=len(HOLES))\n    front.dim((L / 2, -W / 2, 0), (L / 2, -W / 2, H))\n    return sheet          # or [sheet_one, sheet_two] for a multi-sheet drawing\n\n\nif __name__ == \"__main__\":\n    enclosure_drawing()\n```\n\n## `@eng_drawing(out=\"<path>.pdf\")`\n\nDeclares the drawing. The function returns a `Sheet` or a list of them. Calling\nit renders one PDF at `out`, a page per sheet, and returns `[path]`.\n\n`out` is required and must name a `.pdf`; a relative path resolves against the\nscript. cadgen has no default location — where a document belongs is the\ncaller's decision. Both are checked when the module is imported, not when the\ndrawing runs.\n\nThere is no `eng_drawing` CLI verb. A `Sheet` is composed in Python — views,\ntheir placement, and dimensions between points on the part's geometry — and no\nargument list carries that, so the decorator is the whole surface.\n\n## The part\n\n`cadgen.read_step(path)` returns build123d geometry and reads warm from the\nstore. Anchor the path on the script\n(`Path(__file__).parent / \"../STEP/part.step\"`) — it resolves against the\nprocess's working directory otherwise. A live build123d shape works too; a\ndrawing takes geometry, not a file.\n\n## `Sheet(size=\"A3\", scale=1.0, title=..., part_number=\"\", material=\"\", revision=\"A\", author=\"\", units=\"mm\", projection=\"THIRD ANGLE\", notes=(), ink=\"mono\", text_height=3.5, general_tolerance=\"\", revisions=())`\n\n- `size`: `A4`, `A3`, `A2`, `A1`, `A0`, landscape. `width`/`height` in mm.\n- `scale`: drawing scale, 1 = 1:1, 0.5 = 1:2. Dimension values stay true size at\n  any scale — each view divides its measurement by its own scale.\n- `projection`: `\"THIRD ANGLE\"` (default) or `\"FIRST ANGLE\"`. The label and the\n  arrangement `three_views` produces are the same decision, so anything else is\n  refused rather than printed over the wrong layout.\n- `ink`: `\"mono\"` (default) is black ink with grey hidden lines, the printed look;\n  `\"color\"` keeps per-layer colours (red dimensions, green notes) for review.\n- `general_tolerance=\"ISO 2768-m\"`: written as note 1, \"TOLERANCES PER ISO 2768-m\n  UNLESS OTHERWISE SPECIFIED.\" Give one on any drawing a shop will quote from.\n- `revisions=[(\"A\", \"2026-09-01\", \"INITIAL RELEASE\"), ...]`: a revision table\n  (REV, DATE, DESCRIPTION) top-right inside the frame.\n- `sheet.three_views(shape, gap=None, iso=False)` → `(top, front, right)` placed\n  from the part's extents in the arrangement `projection` names, with the gap\n  sized for two rows of dimensions unless you pass one. `iso=True` adds an\n  isometric view in the free corner and returns it FOURTH — unpack four names.\n  Use `view()` with `at=` only for a custom layout.\n- `sheet.view(shape, name, at=(x, y), label=None, hidden=True, centre_marks=True, scale=None)`\n  → `View`. `scale` overrides the sheet's for this view (a pictorial drawn\n  smaller); say so in its label. `name` is one of `top`, `bottom`, `front`,\n  `back`, `left`, `right`, `iso`. The projected geometry is centred on `at`.\n\nA view whose geometry runs off the frame raises, naming the scale that fits.\n\nRunning the drawing also prints what it noticed: a dimension whose points miss\nthe view's geometry (it draws, but it measures blank paper — usually a part\nbuilt from a corner and dimensioned as if centred), and annotation printed over\nother annotation (usually one view's outermost row against the label of the\nview above it, which `three_views(gap=...)` fixes).\n\n## `View`\n\nAll points are MODEL coordinates (3-tuples; a 2-tuple means z = 0). Offsets\nare sheet millimetres.\n\n- `view.overall()`: overall width above and height left of the view, on the\n  OUTERMOST row of each side — the feature dimensions sit inside it.\n- `view.dim(p1, p2, offset=None, text=None, orientation=None, tol=None, fit=None)`:\n  linear dimension. `orientation` is `\"h\"`, `\"v\"` or None (whichever the pair\n  spans more); anything else is refused. `offset` left unset takes the next free\n  row outside the view, so unplaced dimensions never stack on each other or on\n  `overall()`. A number places the line that far from the farther of the two\n  points (not from the view's edge), and its sign picks the side; a dimension\n  between interior points then needs an offset that clears the geometry.\n  `tol=0.1` states ±0.1 as a proper tolerance (stacked, smaller text);\n  `tol=(0.05, 0.02)` states +0.05/-0.02 deviations; `fit=\"H7\"` appends an ISO\n  fit class. `text` overrides the value; `\"<>\"` inside it inserts the\n  measurement.\n- `view.hole(center, diameter, depth=None, thru=False, cbore=(dia, depth), csk=(dia, angle), thread=None, count=None, angle=45, tol=None, fit=None, label=None)`:\n  a hole callout in the standard symbols, e.g. `4× ⌀6.6 ↧12`, `⌀6 THRU`,\n  `⌴ ⌀11 ↧6.5` (counterbore), `⌵ ⌀12 × 90°` (countersink), or `M6x1 - 6H THRU`\n  when `thread` is given; `label=\"WHEEL\"` is appended; `angle` is where the\n  leader leaves the circle, in degrees from horizontal. Prefer it to\n  `diameter()` for any hole a shop drills. Every size must be a positive\n  number, and a hole is either `thru` or has a `depth`.\n- `view.diameter(center, radius, angle=45, text=None)` and\n  `view.radius(...)`: on a circular feature at `center`, plain value only.\n- `view.angle(vertex, p1, p2, offset=14)`: the angle at `vertex` between the legs\n  toward `p1` and `p2`; `offset` is the arc's distance from the vertex.\n- `view.note(text, at, offset=(10, 10))`: a leader from a model point to text.\n\n## Layers\n\n| Layer | Linetype | Weight | Holds |\n| --- | --- | --- | --- |\n| SHEET | continuous | 0.35 | frame |\n| TITLE | continuous | 0.25 | title block lines and text |\n| VISIBLE | continuous | 0.50 | visible edges |\n| HIDDEN | DASHED | 0.25 | hidden edges |\n| CENTER | CENTER | 0.18 | centre marks |\n| DIM | continuous | 0.18 | dimensions |\n| NOTES | continuous | 0.25 | notes, view labels, leaders |\n\nThey exist inside the ezdxf document the page is rendered from; the PDF carries\ntheir weights and dash patterns, not the layer names.\n","skills/gcode/SKILL.md":"---\nname: gcode\ndescription: Slice 3D models into printer-ready G-code with OrcaSlicer, the open-source slicer with built-in profiles for most FDM printers (Prusa, Bambu Lab, Creality, Voron and more). Use when the user wants an `.stl`, `.3mf` or `.obj` model sliced for their printer, as a sliced `.gcode.3mf` or plain `.gcode`, headless with OrcaSlicer's command line or by opening the model in OrcaSlicer. Never contacts a printer.\nlicense: MIT\n---\n\n# G-code\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nSlice with OrcaSlicer, headless (below) or by opening the model in the\nOrcaSlicer app for the user. The agent never contacts a printer: for a Bambu\nLab printer, hand the result to `$bambu-labs`; for any other printer, give the\nuser the `.gcode`.\n\n## Install OrcaSlicer\n\n- macOS: `brew install --cask orcaslicer`. The command line is\n  `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`.\n- Windows and Linux: install a release from\n  https://github.com/OrcaSlicer/OrcaSlicer/releases. The command is\n  `orca-slicer`; on Linux, run it from the AppImage or Flatpak.\n\nBelow, `<orca>` is that command.\n\n## Slice headless\n\nOrcaSlicer's command line (https://www.orcaslicer.com/wiki/cli/cli_mode) needs\ncomplete presets: it doesn't fill in a preset's parent settings, and it slices\nonly with a process whose compatible printers name the printer.\n`scripts/orca_presets.py` (Python 3, no dependencies) writes them from\nOrcaSlicer's presets, searching the user's own first, then OrcaSlicer's\nbuilt-in profiles.\n\n1. Find the user's printer, process and filament presets. Prefer the presets\n   the user prints with; never invent one, since a wrong bed size or\n   temperature can damage the printer.\n\n   ```bash\n   python scripts/orca_presets.py --list machine --match \"MK4S\"\n   python scripts/orca_presets.py --list process --match \"@MK4S 0.4\"\n   python scripts/orca_presets.py --list filament --match \"PLA @MK4S\"\n   ```\n\n2. Write the complete presets:\n\n   ```bash\n   python scripts/orca_presets.py --printer \"Prusa MK4S 0.4 nozzle\" \\\n     --process \"0.20mm SPEED @MK4S 0.4\" --filament \"Prusa Generic PLA @MK4S\" \\\n     --out presets\n   ```\n\n3. Slice. Give `--outputdir` an absolute path:\n\n   ```bash\n   <orca> model.stl \\\n     --load-settings \"presets/process.json;presets/printer.json\" \\\n     --load-filaments presets/filament-1.json \\\n     --arrange 1 --slice 0 \\\n     --outputdir /absolute/path/to/out --export-3mf model.gcode.3mf\n   ```\n\n   This writes `plate_1.gcode`, the plain G-code, and `model.gcode.3mf`, the\n   sliced 3MF, into that folder. The command line reads `.stl`,\n   `.3mf`, `.obj` and `.amf`; export STEP to STL or 3MF with `$cad` first.\n   Override one setting with `--<setting>=<value>`, using the setting's key\n   with hyphens for underscores, such as `--layer-height=0.16`.\n\n## Open in OrcaSlicer\n\nWhen the user would rather pick presets and slice themselves, or no preset can\nbe found, open the model in the app: `open -a OrcaSlicer model.stl` on macOS,\n`Start-Process model.stl` on Windows when OrcaSlicer opens that file type, or\n`orca-slicer model.stl` on Linux. The app also reads STEP.\n\n## Check before printing\n\n- `<orca> model.stl --info` prints the model's size; it has to fit the bed.\n- Check the slice's settings, which OrcaSlicer writes at the end of the G-code:\n\n  ```bash\n  grep -E '^; (printer_model|nozzle_diameter|filament_type|nozzle_temperature|hot_plate_temp|bed_temperature) =|printing time' out/plate_1.gcode\n  ```\n\n  Confirm they match the user's printer and material before anyone prints it.\n\n## Hand off\n\n- A Bambu Lab printer: `$bambu-labs`, with the `.gcode.3mf`.\n- Any other printer: the user prints the `.gcode` from their printer's app, its\n  web interface (PrusaLink, OctoPrint, Mainsail, Fluidd) or an SD card.\n","skills/gcode/agents/openai.yaml":"interface:\n  display_name: \"G-code\"\n  short_description: \"Slice models into G-code with OrcaSlicer\"\n  default_prompt: \"Use $gcode to slice this model for my printer with OrcaSlicer.\"\n","skills/sdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2FSKILL.md -->","skills/sdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Fagents%2Fopenai.yaml -->","skills/sdf/references/design-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fdesign-ledger.md -->","skills/sdf/references/examples.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fexamples.md -->","skills/sdf/references/frame-semantics.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fframe-semantics.md -->","skills/sdf/references/interoperability.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Finteroperability.md -->","skills/sdf/references/llm-guardrails.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fllm-guardrails.md -->","skills/sdf/references/sdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsdf-workflow.md -->","skills/sdf/references/smoke-tests.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsmoke-tests.md -->","skills/sdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fvalidation.md -->","skills/sendcutsend/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2FSKILL.md -->","skills/sendcutsend/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Fagents%2Fopenai.yaml -->","skills/sendcutsend/references/official-sources.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Fofficial-sources.md -->","skills/sendcutsend/references/report-template.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Freport-template.md -->","skills/srdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2FSKILL.md -->","skills/srdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Fagents%2Fopenai.yaml -->","skills/srdf/references/authoring-contract.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fauthoring-contract.md -->","skills/srdf/references/disabled-collisions.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fdisabled-collisions.md -->","skills/srdf/references/end-effectors.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fend-effectors.md -->","skills/srdf/references/planning-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fplanning-ledger.md -->","skills/srdf/references/srdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fsrdf-workflow.md -->","skills/srdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fvalidation.md -->","skills/step-parts/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2FSKILL.md -->","skills/step-parts/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Fagents%2Fopenai.yaml -->","skills/step-parts/references/step-parts-api.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Freferences%2Fstep-parts-api.md -->","skills/urdf/SKILL.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2FSKILL.md -->","skills/urdf/agents/openai.yaml":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Fagents%2Fopenai.yaml -->","skills/urdf/references/authoring-contract.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fauthoring-contract.md -->","skills/urdf/references/design-ledger.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fdesign-ledger.md -->","skills/urdf/references/frame-semantics.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fframe-semantics.md -->","skills/urdf/references/inertials.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Finertials.md -->","skills/urdf/references/meshes.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fmeshes.md -->","skills/urdf/references/urdf-workflow.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Furdf-workflow.md -->","skills/urdf/references/validation.md":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fvalidation.md -->"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\nInstalling text-to-cad rather than developing it? Follow the Install section of\n[README.md](README.md).\n\nThis repo is a workbench for CAD-related agent skills. Treat `skills/` as the\nproduct and `models/` as the shared fixture/artifact area.\n\n## Branch First\n\n`main` is the only branch you develop on: the source tree, and what releases\nare cut from. Branch from `main` and open PRs against `main`; never push it\ndirectly. There is no development symlink layout — every path in the tree is the\nreal file. Installers follow `latest`, and claude.ai's directory\n`claude-plugin`: the plugin alone, one commit per release,\nwritten only by `Publish Release` once the release is on PyPI (see below); never\ncommit to them. `main` stays an installable plugin too: every manifest and MCP\nconfig lives at its root.\n\n## Release Workflow\n\nA pull request that changes `VERSION` is a release, and merging it releases it.\nNever bump during normal development work, and never pick the bump yourself: if\na request to make or ship a release does not name patch, minor, major, or an\nexact version, ask which one. A release pull request comes one of two ways:\n\n- **Its own pull request**, for a release of what `main` already has — what\n  \"make a release\" means unless the user names a pull request to carry it:\n  dispatch `Prepare Release`\n  (`gh workflow run release-prepare.yml --ref main -f bump=<patch|minor|major>`,\n  or `-f set_version=X.Y.Z`). It opens `release/X.Y.Z` with the bump and never\n  merges it.\n- **On the pull request that should carry it**: on its branch,\n  `scripts/release/bump-version.sh <patch|minor|major|X.Y.Z>` sets `VERSION` past\n  `main`'s and stamps the derived metadata and every `cadgen==` pin with it.\n\nMerge a release pull request only on the user's explicit word, once its checks\npass: the merge is the release.\n\n- `Test` runs every job for a `VERSION` change, so a release is tested whole.\n  Its Version Check vets the bump: a branch of this repository only (a fork\n  cannot release), a version past `main`'s and the latest tag, every stamp in\n  step.\n- `Publish Release` (`release-publish.yml`) runs on the merge. It finds the\n  `Test` run that recorded the merged tree as tested in full (else it runs every\n  `Test` job on it first), builds the plugin ZIP, the bundle and the `cadgen`\n  wheel and sdist, installs and exercises the wheel, uploads the pages' source\n  maps to PostHog and the wheel to PyPI, and\n  waits until PyPI's index lists it. Only then does it commit the plugin alone\n  onto the branches installers follow (`scripts/release/plugin_branch.py`):\n  `latest`, which every install command names, and `claude-plugin`, which\n  claude.ai's directory tracks. After them it deploys the docs site and tags\n  (`v<VERSION>`; releases before 0.5.0 are bare\n  `0.4.x` tags) and GitHub-Releases the release commit, with the wheel and sdist\n  that went to PyPI attached as release assets, plus the plugin ZIP that a\n  person uploads to OpenAI's plugin portal, which has no API.\n\nTo finish a run that stopped partway, dispatch `Publish Release` on `main`\n(`publish=false` leaves the GitHub Release as a draft): it publishes the commit\nthat last moved `VERSION`, if that version has no tag yet. `build-test` is the\nrehearsal branch — `Prepare Release` with `-f target=build-test` opens a bump\nagainst it, whose merge is built and installed with no PyPI, branches, docs or\ntag — and is never a release; use it only when the user explicitly asks to test\nthe pipeline.\n\nThe standalone `Deploy Docs` workflow redeploys the docs site from a ref\n(default `main`, or a release tag) without running a release.\n\ncadgen runs as ONE launch command everywhere, `uvx --no-config --managed-python\n--python 3.13 --from cadgen==<VERSION> <tool>` (`cadgen._internal.launch`): the\nplugin server configs and every cadgen skill's SKILL.md carry it, the release\nstamps every pin (`sync-version.mjs`), and `scripts/release/check-version.sh`\nand the tests assert they all equal `VERSION`. uv keeps one installation per\nrequirement and cadgen names its warm daemon after that installation, so the\nserver, every thread and every skill command share one daemon. In a checkout,\ninstall `requirements-dev.txt` (the editable cadgen) and test in agent apps with\n`scripts/install/dev_install.py`, which points both at the checkout.\n`models/` stays on `main` as plain files; nothing installs it.\n`scripts/github-workflows/check-builds.sh` enforces the shipping contract on\nevery push: no tracked symlink, no `.gitattributes` rule that rewrites files at\ncheckout or changes the archive (so no Git LFS; claude.ai's plugin directory\nrefuses them), every tracked file under 5 MiB, no skill reaching into a repo root.\nSee the Releases section in `CONTRIBUTING.md` for the full flow, the resume\npath, the rehearsal, and local/manual fallbacks.\n\n## Repo Map\n\n- `skills/`: agent skills and their references/scripts.\n- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, and the\n  [Agent Plugins](https://agent-plugins.org) standard's `plugin.json` and `mcp.json`:\n  agent plugin manifests. The repository root is the plugin package; its skills are\n  `skills/` directly. Installers take it from the `latest` branch, which\n  `scripts/release/plugin_branch.py` builds from this tree at each release.\n- `models/`: sample and durable CAD/robot-description fixtures.\n- `apps/web/`: the CAD Viewer's React client (its backend is `cadgen.viewer`).\n- `apps/mcp/`: the CAD app agent hosts render: tabs in Codex, cards in Claude Desktop (its server is `cadgen mcp`).\n- `packages/core`: `@text-to-cad/core`, shared CAD/runtime/client code without React.\n- `packages/ui`: `@text-to-cad/ui`, the shared FileViewer, renderers, controls and styles.\n- `packages/cadgen`: the published distribution — STEP/GLB/topology generation,\n  the skill CLI parsers, the CAD Viewer backend + client, and the Node/browser\n  runtimes it executes.\n- `apps/docs/`: documentation site.\n- `apps/api/`: `api.texttocad.dev`, its own Vercel project: cadgen's version feed (read from PyPI) and the CAD app's consented analytics.\n- `tests/`: root-owned test suites for skills, packages, viewer services, and\n  repo-wide policy.\n- `scripts/`: durable repo commands grouped by purpose.\n\n## Repo Rules\n\n- Boundaries and design laws live in each package's README: read\n  `packages/cadgen/README.md` (the laws), `packages/core/README.md`, `packages/ui/README.md`,\n  `apps/web/README.md`, `apps/docs/README.md` and `apps/api/README.md` before changing\n  generation, rendering, storage, layout, or public interfaces.\n- A README holds the laws; the mechanism each law constrains lives one link\n  away, and the README names the link. Read the README, then follow the one\n  link — not the tree. What exists:\n  - `packages/cadgen/`: `STORE.md` (the store contract — sectioned, with a\n    table of contents), `SNAPSHOTS.md` (snapshot `--debug` timings).\n  - `packages/core/docs/`: `render-pipeline.md`, `resource-ownership.md`,\n    `tube-deformation.md`.\n  - `packages/ui/docs/`: `settings-ui.md` (BINDING for any settings control),\n    `render-types.md`, `render-mode.md`, `lod.md`, `storage.md`, `backend.md`.\n- Ships-alone law: `packages/cadgen` (the built PyPI wheel) works in isolation\n  outside this repo, so its markdown must not refer to anything outside the\n  package — enforced by `tests/python/global/test_package_boundaries.py`.\n  Repo-development guidance for it goes in `CONTRIBUTING.md`.\n\n- The README and the docs site's homepage (its copy: `apps/docs/src/lib/content.ts`)\n  share their structure, install message and commands: change them together.\n  The README says more: the steps to install it yourself, and fuller notes under each install\n  (`apps/docs/README.md`).\n- Keep root guidance short. Put domain workflows, CLI details, and validation\n  policy in the relevant `skills/<skill>/SKILL.md` or `references/` file.\n- Keep relevant Markdown docs current when changing behavior, commands, or repo\n  layout, but do not bloat `AGENTS.md`; use it only for durable repo-level\n  rules and pointers.\n- Read `CONTRIBUTING.md` before committing, rebasing, resolving generated-file\n  conflicts, or bumping release versions.\n- A skill must not import another skill, a `skills/` root module, or a\n  repository-root module, and must not add `skills/`, the repository root, or a\n  sibling skill directory to `sys.path`, `PYTHONPATH`, `NODE_PATH`, or any other\n  runtime lookup path. Skills are independent of each other, not of everything.\n- Shared runtime comes from the **`cadgen` distribution**. A skill that uses it\n  defines `cadgen` and `python` in its SKILL.md as the launch command above,\n  pinned to `VERSION` (the release stamps every pin); no skill installs cadgen\n  any other way (no `requirements.txt` naming it), because a second installation\n  is a second daemon. Skills do not vendor it: a skill script is a thin entrypoint whose\n  parser and behaviour live in `cadgen.cli`. Playwright is a cadgen dependency and\n  the first snapshot fetches its headless browser, so no skill has a browser step. cadgen carries\n  the JavaScript it executes too (Node builders, the snapshot browser bundle,\n  the CAD Viewer client), so a skill ships no runtime of its own. Not every\n  skill needs cadgen (bambu-labs, dfam-check, dfm, gcode, sendcutsend,\n  step-parts are cadgen-free); do not add the dependency to a skill that never invokes it.\n- Keep samples and manual CAD/robot-description validation artifacts under\n  `models/`. Automated tests must not read, build or import that sample corpus:\n  generate small fixtures in fresh temporary directories or use tiny test-owned\n  fixtures, with their own cache stores and cleanup. Repo `tmp/` is fine.\n  Enforced by `tests/python/global/test_tests_are_self_contained.py`.\n- Every test runs in CI. Each test file is reached by a runner under\n  `scripts/test/`, every runner is called by a `test.yml` job on the changes that\n  can break it, and a collector that finds nothing fails the run rather than\n  reporting a group that never ran. A test no CI job runs is dead: wire it in or\n  delete it. There are no manual-only test gates. Enforced by\n  `tests/python/global/test_ci_workspace_selection.py`.\n- Tests and CI are short and succinct. Test a contract, a user flow or a fixed bug,\n  once, at the cheapest level that exercises the real path: a unit or jsdom test\n  first, a real browser (WebGL) only for what needs one. Await the condition, never\n  a fixed sleep or a wall-clock bound; a flaky test is fixed or deleted, never\n  retried. CI time is a budget: the `web` job stays within 7 minutes, and a\n  change that lengthens any job says what it costs and why in its PR.\n- Benchmarks under `scripts/bench/` are manual and their output is never\n  committed: reports, logs, profiles and screenshots go to an ignored `tmp/`.\n  Only their pure helper units run in a test runner.\n- The Python floor is `requires-python` in `packages/cadgen/pyproject.toml` and\n  nowhere else. Every cadgen source is parsed against that floor, so syntax\n  newer than it fails here rather than at `pip install` time on a user's\n  interpreter; raising the declared minimum relaxes the check automatically.\n- Reserve `scripts/` for durable repo commands. Do not write temporary,\n  one-off, or local-only helper scripts there; use `tmp/` or `/tmp` instead.\n- cadgen's packaged runtime (`_runtime/node`, `_runtime/browser`,\n  `_runtime/viewer`, and the file tracer every build loads, `_runtime/native`)\n  is BUILT, never committed: the whole directory is\n  gitignored and ships only inside the wheel. Build it with the one bundle\n  entry point, `scripts/bundle/bundle.sh`; `bundle.sh --check` builds it and\n  asserts every required output. Call `scripts/bundle/cadgen-runtime.sh`\n  directly only when debugging one stage.\n- Installs always work from `main`. A command that names no branch installs `main`\n  as it is, so every manifest, MCP config and the marketplace catalog stay at its\n  root, the catalog lists the plugin at `./`, and nothing on `main` points an\n  installer at another branch. The documented commands name `latest` instead: the\n  plugin alone, one commit per release, which `Publish Release` writes once the\n  release is on PyPI, so it installs lean (no monorepo, no npm install of the\n  workspace) and is never ahead of PyPI. `latest` is a channel, so a slower\n  `stable` can join it later. `tests/python/global/test_plugin_manifests.py`\n  holds both halves.\n- Never let a symlink reach the published tree. Agent installers disagree about\n  symlinks and one loses data silently: the Skills CLI dereferences them, Claude\n  Code preserves them, and Codex `plugin add` drops them with no error, shipping\n  a skill with missing files. `scripts/github-workflows/check-builds.sh` enforces\n  this; do not relax it.\n- The CAD Viewer is `cadgen viewer`: the server is `cadgen.viewer` (Python, in\n  `packages/cadgen`), the React client's source is `apps/web/` and its build\n  ships in the wheel at `cadgen/_runtime/viewer` (built, never committed; a\n  checkout serves `apps/web/dist`). The CAD, DXF and robot-description skills document that verb directly.\n  Nothing in `cadgen.viewer` imports the CAD kernel at module scope — the one\n  kernel action, importing a foreign STEP, is a compile job in cadgen's build\n  pool, never work the server process does.\n  Keep repo-level tooling in `scripts/`, not under `apps/web/`.\n- `packages/core` stays non-React. Shared FileViewer/renderers belong in\n  `packages/ui`; host workflow state belongs in apps. Apps never import another\n  app and shared packages never import apps. Root npm workspaces consume compiled\n  package exports; do not add source aliases or nested lockfiles. Preserve app\n  UI/UX and functionality during restructuring; changes are pure refactors.\n- Shared UI must stay platform-agnostic. Apps implement environmental effects;\n  shared features use injected capabilities and named extension slots. Before\n  extending these interfaces, read [the viewer host contract](packages/ui/docs/viewer-host.md).\n- `packages/cadgen` is the whole distribution, not just the Python: artifact\n  generation, the CLI parsers behind every skill command (`cadgen/cli`), the warm\n  build daemon (`cadgen/daemon`), and\n  the JS/SPA assets it executes (`cadgen/_runtime`, built by\n  `scripts/bundle/cadgen-runtime.sh`). Skills consume it as an\n  installed distribution.\n- Create lightweight shared Python packages under `packages/` when a helper\n  should not inherit heavier package dependencies.\n- Use path-targeted search, validation, and `git status`; avoid broad scans over\n  generated CAD artifacts unless the task requires them.\n- Treat `VERSION` as the canonical release version. Do not hand-edit duplicate\n  package, plugin, lockfile, or Python `pyproject.toml` versions;\n  `scripts/release/bump-version.sh` and `scripts/bundle/bundle.sh` stamp them\n  from the canonical version.\n\n## Environments\n\n- Prefer `./.venv/bin/python` for CAD Python work.\n- Keep new branch checkouts and git worktrees lightweight by default. Do not\n  copy `.venv/` or `models/` through `.worktreeinclude`; recreate `.venv/`\n  inside the worktree only when Python dependencies are needed for the workflow.\n- In Codex or Claude Code worktrees, prefer the skill instructions and scripts\n  under the current worktree's `skills/` directory over globally installed\n  skills or plugins from another checkout.\n- Test skills and the plugin in an agent app with\n  `scripts/install/dev_install.py <host>` (`CONTRIBUTING.md`, \"Test In Agent\n  Apps\"). One copy of the plugin per app: never the development install beside\n  the published plugin, or beside the skills installed loose.\n- Install dependencies only for the workflow being changed.\n- Do not commit `.venv/`, `node_modules/`, caches, `tmp/`, local credentials, or\n  printer config.\n\n## Checks\n\nRun the smallest path-targeted check that covers the change. Use broad wrappers\nwhen touching shared surfaces or before handoff:\n\n- Code tests: `scripts/test/test.sh` (JS, API, Python, then policy).\n- Focused runners: `scripts/test/test-js.sh`, `scripts/test/test-api.sh`, `scripts/test/test-docs.sh`,\n  `scripts/test/test-python.sh`, `scripts/test/test-global.sh`.\n  `test-python.sh` takes `--select cadgen|viewer|skills|all` and\n  `--print-weights`; `test-js.sh` takes `--select core|ui|web|mcp|all`. See\n  `scripts/README.md`.\n- In GitHub Actions, `test.yml` runs one job per concern, each only when the\n  change reaches it: `scripts/github-workflows/select_checks.py` maps every\n  changed path to the tests that read it, and the Python jobs run just those\n  files. A test that reads a new kind of path gets a rule there\n  (`CONTRIBUTING.md#ci`); an unknown path, `VERSION`, `packages/core`, the\n  lockfiles and the test machinery run everything, as does a manual dispatch.\n  Required check names stay stable.\n- Canonical release version: `scripts/release/check-version.sh`\n- Packaged runtime builds and is complete: `scripts/bundle/bundle.sh --check`\n- CAD Viewer or shared packages: build exports with `npm run build:packages`,\n  then `npm --prefix packages/core test`, `npm --prefix packages/ui test`,\n  `npm --prefix apps/web run test`, `npm --prefix apps/web run build`.\n  The Viewer is two languages and `npm run test` covers only the client — the\n  backend's suite is `tests/python/packages/cadgen/viewer`, run by\n  `scripts/test/test-python.sh`. Touching `cadgen/viewer/` means running that.\n- Docs site: `npm --prefix apps/docs run check`\n- API (`api.texttocad.dev`): `npm --prefix apps/api test`\n- Targeted Python tests: `./.venv/bin/python -m unittest <changed test paths>`\n\nWhen a task changes what the bundlers consume, run `scripts/bundle/bundle.sh`\nand confirm the change lands in the built runtime. There is nothing to commit:\n`_runtime/` is gitignored end to end, so what a reviewer reads is the source and\nwhat a user gets is the wheel the release builds from it.\n\n## CAD Viewer\n\nThe app-facing playbook lives in `apps/web/README.md`: launcher contract\n(reuse, ports, `--new`), dev vs prod, and the catalog/link-verification\ngotchas. The repo-side half — the lightweight-worktree recipe and\nroot workspace dependencies — lives in `CONTRIBUTING.md` under \"Viewer Development\nIn This Repo\". Read them before starting, stopping, or debugging a Viewer.\nNever stop an instance you did not start; packaged-runtime checks go\nthrough `scripts/bundle/bundle.sh`.\n\n## Git\n\nNo Git LFS, and every file under 5 MiB: the repository root is the plugin, and\nclaude.ai's plugin directory refuses files a filter rewrites at checkout, so\nheavyweight media stays out of the tree. Local hooks live in `.githooks` and\ndelegate build checks through `scripts/git-hooks/pre-commit`.\n","isInternal":false,"tokens":4593,"sizeBytes":18782},{"name":"SKILL.md","path":"apps/web/skills/smui/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/apps/web/skills/smui/SKILL.md","title":"smui","category":"anthropic-skill","format":"markdown","content":"# Shared UI styling for the CAD Viewer\n\nThe CAD Viewer is a Vite and Tailwind v4 host of `@text-to-cad/ui`. Shared\ncomponents and CAD controls live in that package; the web app owns its top bar\nand browser-specific composition. In a repository checkout, read\n`packages/ui/README.md` before changing a\nshared control.\n\nThe package migration is a pure refactor. Preserve the current appearance and\ninteraction behavior of each app: typography, spacing, borders, surfaces,\nresponsive layout, loading states and saved preferences. Moving a component\ndoes not authorize a redesign. Compare changes against the existing app in\nboth color schemes and at narrow widths.\n\n## Tokens and ownership\n\n- Canonical tokens live in\n  `packages/ui/src/styles/tokens.css`.\n  The current palette is neutral, with light and dark variants and the existing\n  radius scale. Reuse those values and semantic utilities such as\n  `bg-background`, `text-muted-foreground`, `border-border` and\n  `text-destructive`.\n- The web entry stylesheet,\n  [globals.css](../../src/client/styles/globals.css), imports\n  `@text-to-cad/ui/tokens.css` and `@text-to-cad/ui/styles.css`. Shared utility classes\n  and assets are built by the UI package; apps do not scan another package's\n  source or duplicate its token definitions.\n- The host supplies the color scheme and applies its existing `.dark` behavior.\n  Keep the CAD scene theme separate from host appearance, using the existing\n  preference adapter and shared renderer contract.\n- Preserve the current font, text case, radius, opacity and shadows of the\n  affected component. There is no additional Nord palette, glass-surface rule,\n  uppercase-label rule or monospace requirement to apply.\n- Import reusable controls through `@text-to-cad/ui/primitives/*` and `cn` through\n  `@text-to-cad/ui/utils`. The class-merging helper lives in\n  `packages/ui/src/lib/utils.js`.\n  Keep existing host-specific controls when their appearance or behavior differs.\n\n## Controls and interaction\n\nUse the existing shared primitives and variants. Preserve their keyboard\nnavigation, focus styling, labels, disabled states and pointer behavior when\nmoving or wrapping them. Icon-only controls need an accessible name; use the\nexisting Lucide icons and tooltips. Status and error text must convey meaning\nwithout relying only on color.\n\nFor rows in a CAD file's panels (its own panel and Display), follow\n`packages/ui/docs/settings-ui.md`. It defines the\nexisting row types, control widths, units, section structure and accessible\nlabel conventions. Reuse those CAD controls inside the renderer instead of\nadding a second settings layout.\n\nKeep loading, empty and error states in their existing positions. Preserve\nfocus and dismissal behavior for menus and dialogs, and retain reduced-motion\nhandling for animated indicators. A visual cleanup must not remove feedback,\nchange a control's availability or alter selection and navigation behavior.\n\n## Validation\n\nCheck the affected app and shared component in light and dark modes, at the\nnormal and narrow layouts, and with keyboard as well as pointer input. For CAD\nchanges, include the file's panels, toolbar, loading/error and preview\nstates; the `packages/ui/docs/render-types.md` cover\ncross-format and theme behavior. Rebuild shared package output before checking\nan app that consumes it, following the package README. Do not use a migration\nor documentation update to change the styling baseline.\n","isInternal":false,"tokens":721,"sizeBytes":3450},{"name":"render-pipeline.md","path":"packages/core/docs/render-pipeline.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/core/docs/render-pipeline.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Render Pipeline\n\n`@text-to-cad/core` exposes a staged render pipeline for shared viewer, docs, and generated\nsnapshot browser-runtime work. A STEP document goes:\n\n```js\nconst source = await loadSource(input, sourceOptions);\nconst model = buildModel(THREE, source, modelOptions);\nconst viewport = renderModel(THREE, model, viewportOptions);\nconst result = await captureModel(viewport, captureOptions);\n```\n\nEvery other file family (GLB, STL, 3MF, URDF, SRDF, SDF) is drawn by ITS OWN scene\nbuilder, the one the viewer's renderer for it calls too: the snapshot page loads the\nfile with that family's loader, builds the scene with that family's builder, dresses it\nin the look the viewer's viewport would, and hands it to the same `renderModel` and\n`captureModel` (`common/headlessScene.js`). No family is flattened into mesh data for a\nrender.\n\nThe stages keep ownership narrow:\n\n- `loadSource` owns STEP source and sidecar loading plus file-kind validation.\n- `buildModel` owns the CAD object graph, records, selection, clipping,\n  materials, topology/display edges, and STEP parameter effects.\n- A family builder owns its scene: `createGlbScene` (`lib/render/glbScene.js`),\n  `buildMeshScene` (`lib/render/meshScene.js`), `createRobotScene`\n  (`lib/urdf/robotScene.js`). Each returns the scene contract of\n  `lib/viewer/sceneContract.js`, and each scene gives a sibling (`createSibling()`): a\n  second scene of the same file with its own objects, materials and look over the first's\n  geometry (`lib/render/sharedGeometry.js`), which the viewer's Preview draws beside\n  its editor. A GLB's sibling is a SkeletonUtils clone wearing copies of the file's authored\n  materials, kept before any look, with a sibling `document` for its own routine; a robot's\n  has its own links and pose, and its own vertex colours. Ownership is\n  [resource-ownership §1](resource-ownership.md#1-shared-ownership-and-disposal).\n- `renderModel` owns renderer, scene, camera, lighting, background, floor,\n  framing, resizing, and render loop concerns.\n- `captureModel` owns deterministic snapshot outputs without filesystem writes.\n\nThe CAD skill's Python snapshot CLI remains responsible for job parsing, path\nresolution, Playwright routing, and writing returned outputs to disk.\n\n## Modules\n\n### `common/viewSettings.js` and `common/sceneSettings.js`\n\n`normalizeViewSettings(display)` is the strict public schema shared with the\nsnapshot CLI. It preserves omitted groups and inherited appearance, defaulting\nonly `mode` to `solid`. A file's modes, in order, are `solid`, `shaded`, `parts`,\n`xray`, `hidden-line`, and `wireframe` (`VIEW_MODE_VALUES`); `grid` is a boolean\nswitch every mode draws, off unless said. Appearance, camera, clip and exploded are\nshared. A mode is a look at its own defaults: it has no settings of its own, and\nanything else (a per-mode tree, a style group) fails validation.\n\n```js\n{ mode: \"parts\", grid: true, camera: { projection: \"orthographic\", focalLength: 50 }, clip: { axis: \"z\", at: 4 } }\n```\n\nPreview's photographic look (Render) is no mode: it is asked for on its own, by the\nviewer's Preview and a snapshot's `preview`, over the same display. Its own settings\n(`PreviewSettings`: `parts`, `lighting`, `background`, `floor`) are the viewer's,\nkept beside the display, never in it (`normalizePreviewSettings`,\n`resolvePreviewSettings`, `previewSettingsChanged`); a snapshot draws Preview at its\ndefaults. `readViewSettings` and `readPreviewSettings` read a stored record setting\nby setting, leaving out what this build cannot read, without rewriting it.\n\n`resolveViewSettings` returns the established renderer groups. Solid is shaded with\nthe model's visible CAD edges where the view offers them (`edges`); Shaded is the same\nsurfaces without; Parts is Solid with the by-part palette. X-ray uses 0.22 surface\nopacity and all edges; Hidden line uses depth-only surfaces and visible edges; Wireframe\nhas no fill and all edges, both lighter on a dark backdrop. With `preview: true` it\nresolves Render whatever the mode: perspective, no edges, no grid and the photographic\nstudio, from `previewSettings`. The modes use the shared camera, orthographic by default.\nThe grid is the same measuring grid in every mode (`viewGridDefaults`). The view's\ncapabilities are explicit injected lists (`viewFeaturesForKind`): a STEP every mode, any\nother 3D file Solid, and Parts where it has several parts (`scenePartCount`); a saved mode\na view does not offer resolves as Solid without rewriting storage. Surface opacity\nmultiplies authored opacity; colours remain presentation settings, never model material\nedits.\n\nClip keeps coordinates below its plane by default (`invert: false`); Reverse (in Clip's axis dropdown)\nkeeps coordinates above it. `clip.at` is the plane's coordinate on its axis in millimetres;\nomitting it cuts through the middle of the authored bounds. A disabled clip cuts nothing.\n`clip.partColors` defaults to true. Caps use each part's normal base material color,\nwithout selection/hover tints or opacity; false uses uniform amber (`0xe9a451`).\nCaps stay opaque even for transparent parts, and outer materials are unchanged.\nEach intersected part has a winding pair and an opaque fill, so holes remain empty;\nthe fill (as wide as the model on the plane) writes the stencil back to 0 wherever it\ncovers, so no part needs a stencil clear of the whole buffer after it. Cap geometry/materials are owned by Clip; stencil\ngeometry is borrowed from the display meshes. Generic scene looks preserve\n`mesh.userData.cadBaseColor` before highlight ink; STEP records use `baseColor`\nand normal effect colors. A mesh with several materials uses its first material\nas its cap color; texture and vertex-color variation are not sampled.\n\n`resolveViewSceneSettings({display, preview, previewSettings, camera, appearance, quality})` is the public\nViewer/snapshot scene-policy boundary (`preview`: Render over the display, as above). Its `view` is the full grouped state;\n`display` is the historical internal draw policy, including `surfaces` and\nedge color/visibility. `camera` combines the top-level pose with the grouped\nlens. Public top-level and per-output cameras contain pose/framing only;\nprojection and focal length belong to `display.camera`. Focal length ranges\nfrom 20 to 200 mm. `orthographicHalfHeight` is the positive pre-zoom vertical\nhalf-extent and may be retained while perspective is active.\n\nThe resolver's `render.enabled` means lighting, background or floor needs the\nstudio scene helper. `render.configuration.lighting.enabled` independently\ncontrols photographic illumination and PMREM allocation. `theme` retains the\nneutral workbench recipe whenever photographic lighting is disabled, including\nwhen a custom background or floor is present. Consumers build CAD models with\n`surfaceSettings: resolved.view.surfaces` and the resolved edges; they must not\nreapply historical mode-owned edge forcing. `resolveSceneSettings()` remains an\ninternal compatibility resolver for older low-level consumers.\n\nBackground opacity follows the ordinary alpha convention: 0 is transparent and\n1 is opaque. Fractional alpha is carried by the renderer clear color with\n`scene.background = null`, so viewer checkerboards and PNG captures agree. Over that\nclear the studio draws its backdrop (`studioBackdrop.js`), first of everything and\nwriting no depth: the background color as a seamless sweep, lifted toward white\nbehind the middle of the frame, shaded toward the corners and, by how far the view\nray looks up, above the horizon (`PHOTOGRAPHIC_STUDIO_BACKDROP`; the shade is a larger\nshare of a dark color than of a light one). It writes the clear's alpha, premultiplied\nas three's clear is, so a half-transparent background keeps its gradient; at alpha 0 it\nis not drawn, and a caller that renders with `scene.background` taken away and a clear\nalpha of 0 (a thumbnail) gets nothing of it. The floor's mirrored draw leaves it out.\nThe floor is independent of background alpha. Render's stands at authored\nZ=0 (`placement: \"origin\"`, `RENDER_FLOOR_PLACEMENT`, the Render recipe's backdrop\ndefault too); `placement: \"lowest\"` follows the current model minimum without moving\ngeometry or lighting. Its double-sided plane uses one transparency pass,\nkeeping geometry below the origin visible. Depth fitting includes the actual floor elevation;\northographic views can use a signed near plane when the floor crosses the eye.\n\nLighting quality is `preview` or `final`, mapping to standard/high scene policy.\nThe viewer passes `lightingQuality: \"preview\"`; the CLI baseline is `final`.\nThat environment default also feeds Preview's default comparison and does not pin a saved\noverride. An explicit Preview `lighting.quality` wins in the viewer; a snapshot's `preview`\nis always `final`.\nFinal uses the bounded L3 mesh rung, a 0.25px viewport target, 4096px spotlight\nshadows, a 512px procedural environment and 2x snapshot capture, and a still view\nin the viewer converges over 64 samples of the studio's frame (Preview: 32). Technical\n`quality.tessellation` and `output.renderScale` remain explicit overrides.\nPNG output retains requested dimensions by resampling the full drawing buffer.\n\nThe two studios use one physical Render pipeline. A neutral HDR key card, a rear\nfill card, a side bounce card and the studio's fixed soft sources\n(`PHOTOGRAPHIC_STUDIO_PANELS`: a large overhead softbox and two tall strip boxes\nabout 75 degrees either side of the default camera) hang in a studio sweep and\ngenerate a procedural PMREM for authored PBR reflections; one aligned,\nmodel-scaled SpotLight supplies direct illumination and PCF shadows. Softbox\nsize changes every source's area and bounded shadow softness while keeping each\nsource's total flux stable. Fill sets both fill cards' radiance as a fraction of\nthe key card's. Rotation moves the direct light and `scene.environmentRotation`\ntogether around CAD Z. An overhead side key reveals depth; the rear fill keeps\nhorizontal reflections readable and the bounce, low on the key's far side,\nlifts the faces the key cannot reach (an iso view's right-hand side). The\nsoftbox and strips are brightest along their centres and fall off toward their\nedges, and the sweep (`PHOTOGRAPHIC_STUDIO_ROOM_RADIANCE`: 0.14 overhead, 0.07\nat the horizon and a light floor's 0.3 below, easing between them) is dimmer\nthan they are, so polished and satin metal show defined gradients and highlight\nlines between darker gaps — what reads as metal rather than grey plastic — and\ndark glossy parts keep their color. Nothing is a void: a face turned away from\nevery source keeps a soft fill. Environment radiance and direct illumination\nshare a calibrated zero-EV lighting budget. Khronos PBR Neutral tone mapping is\nfixed; `toneMappingExposure` is `2 ** exposure`. Light and dark differ only in\ndefault backdrop color.\n\n#### The studio's frame\n\nRender's lighting is drawn through the studio's frame (`studioFrame.js`), which\n`applyPhotographicStudio` keeps as `state.frame` while its lighting is on, and the viewer\nand the snapshot both draw the scene through. Three draws the scene as before, but into an\nXR-flagged target of the frame's (`isXRRenderTarget`), which three draws exactly as the\ncanvas, tone mapping and colour space included, so no material compiles a second program;\nits depth is kept. Small full-screen passes then add:\n\n- **Ambient occlusion.** Ground-truth AO (GTAO, after XeGTAO) from that depth alone, at half\n  the drawing buffer's resolution: the horizon either side of each pixel along three screen\n  directions, four steps each, within a radius of 0.1 of the model's rest radius (never more\n  than a fifth of the view's height). Every sample is read at a full-resolution pixel's\n  centre; a depth read from one pixel and placed at another turned sloped faces into\n  staircases of false horizons. A depth-aware blur smooths it, and the composite upsamples it\n  by depth, so it never bleeds across a silhouette, and darkens the scene's colour by it in\n  linear light (`power` 2). The floor writes no depth, so it keeps its own contact shadow;\n  the background is never darkened. This is what reads as a photograph rather than a\n  render: the seam between a cover and its housing, the space between stacked plates, a\n  bolt head on its plate, the inside of a bore.\n- **Convergence.** A still view is the average of `samples` samples, each blended into a\n  half-float accumulation at `1/(n+1)` and presented with a level of dither. Every sample but\n  the first moves the camera a fraction of a pixel (Halton 2, 3), so edges and fine\n  highlights are anti-aliased; turns the occlusion's directions (interleaved gradient noise\n  per sample), so it converges smooth; and moves the key light to a point of its softbox's\n  face (`sampleKey`: R2 over the key card's face, `PHOTOGRAPHIC_STUDIO_SOFT_SHADOW.spread`\n  of it, scaled by Size), so the key's shadows become a softbox's: crisp where a part meets\n  what it shades and soft away from it, on the model and, through the floor shadow's\n  composite, on the floor. A shadow pass that only moved the key holds the floor shadow's\n  heights (`holdHeights`) and re-composites its key shadow alone.\n\nThe frame's caller says whether a frame is `still`: nothing but, perhaps, the camera changed\nsince the last. A still frame is the next sample unless the camera has moved the model by\nmore than `STUDIO_FRAME_DRIFT_PIXELS` (0.25) on screen since the picture's first sample, so the\ntail of a damped orbit still converges; anything else (a camera that moved, a pose, a\nroutine's frame, a setting, a resize) starts again from the first sample, which is a whole\npicture of its own: the key at rest, the occlusion denoised in space. `render` answers whether\nsamples are still owed (`refining`); the caller asks for the next frame while they are, and\nfor none once the picture has converged. A moving view therefore pays for one draw and the\nsmall passes a frame; the samples only while it rests.\n\nMeasured at 3024 × 1964 on an M1 Max over 0.6 M and 3 M triangles: a moving frame\n3.1 → 4.5 ms and 5.0 → 6.6 ms, a sample 5.2 and 7.8 ms, all 32 samples back to back 155\nand 240 ms (in the viewer, a sample a display frame: about half a second). Memory is the scene target (8-bit colour and depth-stencil, multisampled as\nthe canvas is: four samples, two on a canvas of pixel ratio 2 or more), its resolve, a\nhalf-float accumulation and two half-resolution occlusion targets: about 200 MB at that\nsize, released with the studio. Software rendering has no frame: three's single draw, as\nbefore, whose key casts no shadow to soften.\n\n`applyPhotographicStudio(THREE, runtime, configuration, options)` owns the\nsynchronous light, ground and renderer state and updates those objects in place.\n`disposePhotographicStudio(runtime)` releases only those objects, the studio's frame\nincluded (`applyPhotographicStudio`'s `frame`: false for none, or its `samples` and\n`multisample`). The caller\nseparately owns the PMREM returned by\n`createEnvironmentResource(renderer, configuration, {size})` — synchronous GPU\nwork, returned directly rather than as a promise — assigns its\ntexture to `scene.environment`, and releases it through\n`disposeEnvironmentResource()`. `environmentResourceIdentity()` includes\nsoftbox size, fill, and PMREM resolution; it excludes rotation so rotating the\nrig is a live scene update rather than an environment rebuild. A resource also\nexposes `readPixels()` for transferring its half-float PMREM from worker WebGL.\nThe interactive UI prepares and caches these maps off the main thread; snapshot\nrendering can keep the synchronous caller-owned path.\n\nThe floor has no edge (`PHOTOGRAPHIC_STUDIO_FLOOR_FADE`): from 1.6 times the model's\nreach (the larger of its rest radius and its footprint's half-diagonal) it fades out\nto its inscribed circle at 6 times it, where the two-triangle plane ends, so a low or\nwide view shows no horizon line and no corner, only the backdrop. The fade is applied\nto the floor's alpha after its reflection is laid over it, so the reflection fades with\nit; it is squared, so its tail is long even foreshortened at a grazing angle. Camera\nfitting keeps `PHOTOGRAPHIC_STUDIO_STAGE_RADIUS_MULTIPLIER` as far-plane padding, far past\nthat rim. Near-plane fitting includes the visible floor as well as the model.\n\nThe physical floor is mostly self-lit backdrop color and receives no shadow\nmap; its shadow is a contact layer drawn over it (`studioContactShadow.js`):\ndarkest where the model meets the floor, a broad occlusion fading with height,\nand the key's cast shadow crisp near the model and softening into a wide, light\npenumbra away from it. The layer is one texture over a square fitted to the\nrest placement (`PHOTOGRAPHIC_STUDIO_CONTACT_SHADOW`), as deep as the floor is\nopaque, and hidden while the studio's lighting is off; a floor at zero opacity\nis not drawn, so it bakes nothing. It is baked in two steps: the heights, a\ndepth pass of the shadow casters seen up through the floor by a probe light\nthat is never added to the scene, and the composite, three small full-screen\npasses over those heights and the key's own shadow map. Both are due only when\nthe key's shadows re-render: a sentinel in the studio, a shadow caster of zero\narea that every shadow pass and the main pass draw (it rasterizes nothing in\neither), notices that pass. The composite runs in that frame, so the key's cast\nshadow on the floor follows every frame a model moves in. The heights do too,\nunless the caller passes a `heightInterval` (`applyPhotographicStudio`'s\n`contactShadow` option): then a scene that keeps changing re-measures them at\nmost that often, and once more when it stops, in a frame the layer asks for\n(`requestFrame`), so what is shown at rest is exact and only the contact\ndarkening lags while a model moves. The interactive viewer re-renders shadows\nonly for a change that can alter them, never for a camera move or a highlight,\nso it bakes nothing on those frames; the snapshot renderer, which renders\nshadows every frame and passes no interval, bakes both steps every frame. A\nruntime that renders in software (`softwareRendering`) gets no contact layer:\nits key casts no shadow either. A transparent legacy backdrop's floor stays a\n`ShadowMaterial` catcher of the key's shadow.\n\nThe floor and its shadow layer are dithered: each adds up to one 8-bit level of\nnoise as it is drawn (the floor's divided by its alpha, so its blend keeps it\nwhole; the layer's drawn premultiplied, so the blend adds exactly that noise over\nwhatever floor is under it). A dark floor's shading spans only a few levels of\nthe canvas, and rounding drew it as wavy contour bands; the noise is too fine to\nsee and leaves every average colour where it was. A snapshot drawn at a render\nscale above 1 averages that scale squared of drawn pixels into each one it keeps,\nwhich quietened the noise to a fraction of a level and let the rounding band\nagain: it passes its render scale as `applyPhotographicStudio`'s `ditherScale`,\nwhich spreads both noises that much wider (`syncDitherScale`). The viewer keeps\n1, and at 1 nothing is defined: its shaders are the ones they always were.\n\nThe floor's finish is `backdrop.groundFinish` (`PHOTOGRAPHIC_STUDIO_FLOOR_FINISHES`):\n`matte`, the default, or `glossy`, a glossier surface that also reflects the model\n(`studioFloorReflection.js`). Before each frame of the scene (its `onBeforeRender`)\nthe scene is drawn again from the camera mirrored in the floor, at half the\ncanvas's resolution; three small passes fade and soften it with each reflected\npoint's height (crisp where the model meets the floor), none of them sampling the\ntarget it draws into, a draw WebGL refuses, and the floor lays it over\nitself with a Fresnel weight, in display colour, so a light floor shows it as a\ndark one does. The mirrored draw runs when the camera moved or the frame re-renders\nshadows (it renders them, and the frame keeps them); a frame for a highlight keeps\nthe last reflection and catches up within 400 ms; a snapshot draws it every frame.\nIt runs before the frame, at the frame's own render depth and into a target three\ntreats as the canvas (`isXRRenderTarget`): drawn nested in the frame, or as an\nordinary target, it made every lit material rebuild its program key twice a frame,\nwhich cost more than the draw. A matte floor allocates none of it; turning the\nfloor matte, the studio's lighting off or the floor off releases it.\n\nEnvironment radiance and direct illumination are calibrated together at zero EV\nacross colored assemblies, gray mechanical models, and authored metal/plastic\nfinishes.\n\n#### What Render's public contract does NOT have\n\nNo global material, no color grading, no arbitrary lights, no floor physics, no\nglow controls. Adding one of these is a change to the law in the package\nREADME, not a new option here.\n\nMaterials reaching that scene stay authored. STEP package material channels are\ninputs to the rig: assigned sparse materials use roughness 0.42, metalness 0.03,\nclearcoat 0, clearcoat roughness 0.26, and opacity 1; an absent base color\nretains the STEP color, and authored opacity multiplies its source alpha.\nA GLB, a mesh file and a robot reach the studio as the scene their viewer renderer\nbuilds. A GLB is its native glTF hierarchy, so its textures and PBR channels stay\nattached and Render wears them exactly as authored; an STL, a 3MF and a robot author no\nfinish, so they wear the studio's surface over their colours (a 3MF's object colours, a\nrobot description's colour, else its link mesh's own).\n\nSnapshots use the same grouped display contract. Photographic lighting supports\nonly the `view` capture mode. Camera,\nselection, clipping, exploded view, guides, part colour, animation, video,\nkinematics, robot joints, tessellation quality, per-output cameras and output\nsizing remain composable.\n\nPhotographic lighting creates its WebGL renderer with\n`logarithmicDepthBuffer: false`. Three's logarithmic depth shader path does not\nproduce usable contact shadows. Render callers fit ordinary-depth near/far\nplanes to current model bounds with `fitCameraDepthToBounds(camera, bounds)`.\nThe interactive viewer uses this fitted conventional depth for every preset so\nsettings can enable shadows without replacing its renderer/canvas. The fit also\nuses visible records for closeups, the floor's actual elevation, and the grid's\nplane and bounds independently of Floor. Otherwise the subject's near plane\nclips foreground guides, or the far plane truncates their finite span. A\nperspective camera passes its `pivot` (the point it looks at): its near plane\nnever comes nearer than 1/256 of the pivot's depth, so a closeup the fit cannot\nmeasure (the camera inside a part's own box, or a routine deforming what it\nmeasures) keeps its depth resolution instead of making close surfaces fight.\nSnapshots draw every preset the same way: ordinary depth, fitted to each output\nwith the same inputs (its placed records, the studio floor's elevation, the\ndrawn grid's span and the camera's target as pivot). A logarithmic buffer would\nalso lose the instanced CAD edges, which write no logarithmic depth: under a\nperspective camera every edge failed its depth test against the surfaces.\n\n### `common/source.js`\n\n```js\nimport {\n  loadSource,\n  stepParameterRuntime\n} from \"@text-to-cad/core/common/source.js\";\n```\n\n`loadSource(input, options)` returns a normalized STEP render source:\n\n```js\n{\n  kind,\n  meshData,\n  selectorRuntime,\n  displayEdgeRuntime,\n  stepParameterSource,\n  resolved,\n  url,\n  glbUrl,\n  cadPath\n}\n```\n\nAccepted input fields:\n\n- `kind`: `step` or `stp`, or inferred from a URL. A GLB, STL, 3MF, URDF, SRDF\n  or SDF is refused by name: it is drawn by its own scene builder\n  (`common/headlessScene.js`), never flattened into mesh data here.\n- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.\n- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.\n- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.\n- A caller that passes a `resolved` packet together with any source URL must also pass\n  `resolved.inputPath`. Render asset caches are page-lifetime, so a resolved job has to name the\n  source its cache entries belong to; a resolved job without `inputPath` is rejected rather than\n  cached under an unidentified source. Callers with no `resolved` packet (the interactive viewer and\n  the docs hero renderer) render one source per page and need nothing.\n- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller\n  already owns sidecar loading.\n- `kinematics`: pose values for the model's kinematics — a declared preset name,\n  or `{dof: value}`. Same spelling as the `--kinematics` flag, the snapshot job\n  key and the sidecar section.\n- `stepParameterUrl` or `resolved.stepParameterUrl`: model sidecar\n  (`.step.json`) URL, whose `kinematics` section is compiled here.\n- `quality.tessellation`: explicit STEP tolerances in every mode. When\n  omitted, `preview: true` selects the bounded final mesh rung.\n\nSTEP-only options are rejected for non-STEP sources. The old shared `params`\nfield is rejected, and so is the retired `stepParameters` spelling; use\n`kinematics`.\n\nUse `stepParameterRuntime(stepParameterSource)` to turn the loaded parameter\nsource into the runtime object `buildModel` accepts.\n\n### `common/cadScene.js`\n\n```js\nimport {\n  buildModel,\n  fitCameraToModel\n} from \"@text-to-cad/core/common/cadScene.js\";\n```\n\n`buildModel(THREE, source, settings)` returns a model API:\n\n```js\n{\n  source,\n  meshData,\n  root,\n  modelGroup,\n  edgesGroup,\n  displayRecords,\n  records,\n  bounds,\n  restBounds,\n  radius,\n  runtime,\n  update(nextSettings),\n  dispose()\n}\n```\n\n`source` can be a `loadSource()` result or raw mesh data. The model owns the\nThree.js object graph and its mutable state.\n\n`bounds` follows the live pose — what lighting, the floor, shadows and clipping\nneed. `restBounds` is the same model at its ZERO pose, before a parameter, mate\nor animation frame moved a record, and it is what a camera fit is grounded on so\nthat posing a model never re-frames it. A package that declares its whole box\n(assembly.json's `bbox`, `declaredBounds` in its composition) rests in that box,\nas the scene contract has it: the viewer frames and grounds it, and a snapshot\nsizes its ground and radiates an exploded view from it. A family scene carries\nits own (`restBounds` in the scene contract): a robot's is every joint at its\ndefault.\n\nCommon settings:\n\n- `theme`: normalized or raw CAD scene settings. Render passes none and supplies\n  `materialSettings` instead.\n- `displayMode`: `shaded`, `shaded_edges`, `transparent`, `hidden_edges`,\n  `hidden_lines_removed`, `unshaded`, or `wireframe`.\n- `edgeSettings`: display-owned CAD edge style. Themes do not own edges.\n- `materialOverrides`: sparse explicit PBR overrides; authored PBR otherwise\n  wins over studio material fallbacks.\n- `scale`/`sceneScale`: CAD or robot scene scale.\n- `selection`: internal selection/filtering state. `focus` and `hide`\n  filter rendered parts before records are built. Viewer-only fields such as\n  `selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.\n- `clip`: normalized clip-plane settings.\n- `stepParameters`: compiled kinematics runtime object, from\n  `stepParameterRuntime()`.\n- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.\n- `renderPartsIndividually`: build per-part records instead of a whole mesh.\n- `edgeRendering`: declarative edge rendering configuration.\n\nDeclarative screen-space edge rendering:\n\n```js\nbuildModel(THREE, source, {\n  edgeRendering: {\n    mode: \"screen-space\",\n    Line2,\n    LineGeometry,\n    LineSegments2,\n    LineSegmentsGeometry,\n    LineMaterial,\n    wireframeEdgeColor: \"#111827\"\n  }\n});\n```\n\nThe model keeps screen-space line material bookkeeping internal through\n`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.\nCallers should not provide callbacks that create edge objects.\n\n`model.update(nextSettings)` merges mutable settings, rebuilds geometry only\nwhen needed, reapplies material/selection/clip/STEP parameter state, and returns\nthe same model API. `model.dispose()` releases model-owned scene objects and\nSTEP parameter cleanup hooks.\n\nSource colors: a GLB's material base colors and its `COLOR_0` vertex attribute\nboth count as source colors (`lib/render/glbMeshData.js`). A vertex-colored\npart renders on a white base so the ramp shows unmixed — an FEA result or scan\nheatmap keeps its colors even when the file declares no materials at all — and\n`overrideSourceColors` in material settings replaces both kinds with display\nfills.\nPackage components carry no vertex colors; their coloring is the descriptor's\noccurrence/component/face colors.\n\n`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic\ncamera framing helper used by interactive rendering.\n\n### Display modes, CAD edges and geometry sharing\n\nInternal draw modes are `shaded`, `shaded_edges`, `transparent`,\n`hidden_edges`, `hidden_lines_removed`, `unshaded`, and `wireframe`. Public\npresets resolve to those implementation details plus independent surface and\nedge settings; never feed a public preset into `normalizeDisplayMode()`. Normal CAD\nkeeps authored albedo, opacity and finish. Workbench PBR channels are fallbacks;\na small neutral reflection environment makes authored metals readable without\nloading the photographic studio.\n`resolveDisplayMaterialSettings()` applies the shared Original, Single color\nand Color by part policy without app state.\n\n**Scene geometry is the tessellator's INDEXED output.** A surf component's\n`meshData` shares the tessellation's vertex, normal and index buffers by\nreference (a decoded `.tess` cache entry is copied out of its one entry\nbuffer) and is never expanded per triangle corner.\n\n**CAD edges are not a surface shader.** `surfMeshData.js` emits indexed line\nsegments (`cadEdgePositions` + `cadEdgeIndices` + `cadEdgeClassRanges`, from\nthe same tessellation's boundary polylines, ~1.5 bytes per surface triangle)\nand `cadScene.js` draws them as ONE instanced screen-space line draw per\ncomponent (`cadEdgeInstances.js`): the instances are every (segment,\noccurrence) pair, decoded in the vertex shader from a per-component segment\ntexture (32 B per drawn segment, cached on the component) and a per-set\ninstance texture (128 B per occurrence: matrix, colour, opacity, visibility,\nhighlight).\n\n**Every mode draws the same edges, Wireframe included.** Wireframe is the CAD\nedges through the model with no fill, never the tessellation's triangles: a\nswitch to it reuses the segment textures (or, for a baked mesh without segments,\nthe one cached edge geometry per component, keyed by no mode) that Solid, X-ray\nand Hidden line draw, so it builds nothing per triangle. It once built three's\n`WireframeGeometry` over every triangle on the main thread: 10 s and +1 GB on a\n437 MB STEP, for a picture that read as a solid blob.\n\n`cadInk.js` fixes one dark model-edge palette and nominal widths per class:\nfeature 1, tangent 0.65, seam 0.8, and degenerate 0 (hidden). Public\n`display.edges` exposes enabled, visible/all visibility and color; widths stay\nrenderer-owned and the default color preserves the restrained class palette. Grid settings keep\n`enabled` plus optional `color`, `opacity` and `density`; omission follows the\nappearance's graph-paper ink (`GRID_LINE_DEFAULTS` in `stageGrid.js`), while an\nexplicit color applies to every minor and major line but never the red X and\ngreen Y axes. Viewer and snapshots draw the same grid (`createGridMesh`): its\nstep, majors and long distance fade are in `packages/ui/docs/render-mode.md`.\nThe Axes line is the blue Z axis.\nAppearance updates preserve model lighting, materials, geometry,\nsegment textures and occurrence slots; only the canvas and guides adapt.\nAn edge color edit keeps them too: `model.update` recolors the live edge draws\nin place.\n\nA thickness is a FULL width in DEVICE pixels — every line shader normalises its\nextrusion by the drawing buffer, never the CSS size, and the fragment stage\nfilters a box with a symmetric ±0.75 px kernel. Integrated coverage equals the\nnominal width even for subpixel lines, and zero width has zero coverage. Both\nline paths share the filter; antialiasing does not inflate thin lines or depend\non the framebuffer sample count.\n\nPer-occurrence highlight, dim, hide, focus, exploded placement and selection\nare slots in that texture, written by the same record passes\n(`applyDisplayRecordTransform`, `applyPartVisualState`,\n`syncRecordEdgeMaterials`) that drive a plain line object; highlighted\noccurrences draw in a second pass at the highlight render order. A deformed\ntube leaves its slot for private screen-space lines, one per drawn class, that\nbend with the surface and preserve the same class weights and colours.\nBasic-only hosts use separate per-class materials too, so appearance changes\nretain private edge geometry and cannot recolour another scene's component.\nGPU cost per component: two textures, one 4-vertex quad, two materials, one\ndraw call (+1 while any occurrence is highlighted).\n\n**One upload per component.** Geometry built from a shared component is cached\non the component object (`part.sourceMesh`), never on the composed package\n`meshData`: a package is re-composed on every progressive publish and LOD swap,\nand every occurrence, publish and swap reuses the one upload. A publish of the\nsame model reaches the live scene through `api.update({ source })`, which\nreconciles records by occurrence id — records already on screen keep their\nmesh, materials, visual and deformation state and BVH; only new occurrences are\nbuilt and only departed ones disposed (ownership and disposal:\n[resource-ownership.md](resource-ownership.md)). Effects that change vertex\npositions or normals acquire writable attributes before deforming them;\nmaterial refreshes leave component data unchanged. This keeps large assemblies\nfrom duplicating these buffers for display. Assemblies keep geometry in their\ncomponent buffers; they allocate no combined copy of all positions, normals and\nindices. Rendering visits the placed components directly, and\nthe Viewer accepts that component geometry.\n\n### `common/renderModel.js`\n\nUse this module for interactive browser canvases, including the docs hero.\n\n```js\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n```\n\n`renderModel(THREE, model, options)` returns an interactive viewport API:\n\n```js\n{\n  THREE,\n  model,\n  renderer,\n  scene,\n  camera,\n  ready,\n  resize(),\n  render(),\n  start(),\n  stop(),\n  capturePng(),\n  dispose()\n}\n```\n\nCommon options:\n\n- `canvas`: existing canvas for the renderer.\n- `hostElement`/`container`: element used for responsive sizing.\n- `renderer`: caller-owned renderer. If omitted, one is created.\n- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are\n  created.\n- `theme`/`themeSettings`: background and lighting settings.\n- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,\n  `logarithmicDepthBuffer`, `shadows`: renderer controls.\n- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.\n- `pixelRatio`, `maxPixelRatio`: output density controls.\n- `autoResize`: set `false` to disable `ResizeObserver`.\n- `autoStart`: set `true` to start an animation loop.\n- `autoRender`: set `false` to prevent the initial render.\n- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.\n- `disposeModel`: set `false` when the caller will dispose the model.\n\n`dispose()` stops animation, disconnects resize observation, removes the model\nroot from the scene, and disposes the created renderer/model unless ownership\nwas explicitly retained by options.\n\n### `common/renderMeshScene.js`\n\nUse this module for deterministic headless snapshot rendering.\n\n```js\nimport {\n  renderJobContext,\n  modelOptionsForRenderJob,\n  renderModel,\n  captureModel,\n  renderMeshJob\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n```\n\n`renderJobContext(meshData, job)` resolves the shared scene contract plus\nsnapshot-owned output policy: display, camera, quality, scene scale, outputs,\nSTEP topology edge visibility, and warnings. Studio scene quality is Preview's final\nwhen the job asks for `preview`; `job.quality` contains technical tessellation only. A\nfamily scene passes `{ bounds, partCount, explodeParts }` for `meshData`; its job's kind\nresolves the view features (`viewFeaturesForKind`): anything but a STEP has no edges,\nParts only with several parts, and Explode only for a GLB of several.\n\n`modelOptionsForRenderJob(context, job)` converts that policy into\n`buildModel()` settings.\n\nSnapshot `renderModel(THREE, model, { job, context })` holds either a CAD model from\n`buildModel` or a family scene wrapped by `headlessSceneModel`; only a CAD model has a\nruntime, so only it takes the exploded view, topology edges and screen-space line\nwidths. It returns a headless viewport:\n\n```js\n{\n  THREE,\n  model,\n  scene,\n  renderer,\n  orthographicCamera,\n  perspectiveCamera,\n  context,\n  sceneBuildStarted,\n  ready,\n  dispose()\n}\n```\n\nThis `renderModel` is intentionally separate from `common/renderModel.js`.\nRender is drawn through the studio's frame, every sample of it at once\n(`renderStill`: `PHOTOGRAPHIC_STUDIO_FRAME.stillSamples`, 32, re-rendering shadows only for a\nsample that moved the key), with no MSAA of its own: the snapshot supersamples at its render\nscale. A video's frame and a software renderer draw one sample (`snapshotStudioSamples`).\nIt uses snapshot sizing, studio environment, the shared stage floor/glow/shadow,\nmodel-scaled lights with a fitted shadow frustum, canonical camera projection,\nand deterministic renderer settings. Automatic perspective cameras fit the\ncurrent visible vertices to `output.padding` when `output.tightFrame` is true;\notherwise they fit the model bounds. An explicit camera position is never\nreframed. Video capture fits its precomputed sequence-union bounds once so the\ncamera does not breathe between frames. As in the viewer, a still's studio floor\nand its contact shadow stay sized and centred on the rest placement however the\nmodel is posed or exploded (`groundBounds`), and an exploded view radiates from\nthat rest box.\n\n`captureModel(viewport, { job })` returns data only:\n\n- `mode: \"view\"`: PNG data URLs in `outputs`.\n- `mode: \"list\"`: part list and bounds. A CAD model lists its composed part\n  occurrences; a family scene lists what it drew, one row per mesh, in its scene\n  graph's order (a robot's scene attaches a link's meshes before the joints it\n  carries, so its rows run down the tree from the root).\n\nIt does not write files. The CAD skill snapshot CLI writes the returned data to\ndisk. Consumers use compiled `@text-to-cad/core` exports; generated snapshot browser assets\nbundle this entrypoint into cadgen's packaged runtime (`cadgen/_runtime/browser`).\n\n`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,\nbuilds a model, renders/captures it, and disposes owned resources.\n\n### `common/headlessScene.js`\n\nThe snapshot page's half of \"one scene builder per file family\". `headlessSceneFamily(job)`\nanswers, from the job's resolved kind, the family whose loader and builder draw it (GLB;\nSTL and 3MF; URDF, SRDF and SDF), or null for a STEP document. A family is\n`{ load(job, { resources }), build(THREE, loaded, job), release(loaded) }`, each a call to\nthe module the viewer's renderer calls: `loadRenderGlbDocument` + `createGlbScene`,\n`loadRenderMeshByUrl` + `buildMeshScene`, `loadRobot` + `createRobotScene`. A robot is\nposed as it is built, at `robotOpeningPose` (every joint's default, then an SRDF's `home`\ngroup state: where the viewer opens it) with the job's `jointValues` on top.\n\n`headlessSceneModel(THREE, scene, headlessSceneDress(context.sceneSettings))` dresses the\nscene exactly as the viewer's viewport dresses one it adopts, with the look\n`resolveSceneSurfaceLook` resolves (`common/sceneSettings.js`, which the viewport calls\ntoo) and shadows while the lighting is on, and exposes what `renderModel` and\n`captureModel` read of any model: `root`, `bounds`, `restBounds` (what sizes the ground),\n`keepsAuthoredFinish`, the drawn meshes for the tight frame, `listParts()` and `dispose()`.\nThe still's tight frame reads a skinned or morphed mesh where the GPU draws it.\n\n## Kinematics\n\nTwo names, two things, and they are not interchangeable:\n\n* `kinematics` is the POSE INPUT — what `loadSource()` and the snapshot job\n  packet take. A declared preset name, or direct DOF values:\n\n  ```json\n  { \"drive\": 180, \"ringVisible\": false }\n  ```\n\n  Animation envelopes (`animate`, `fps`, `durationSeconds`, `duration`, `loop`)\n  are retired and throw: a still renders one frame at the given values.\n\n* `stepParameters` is the compiled RUNTIME OBJECT that `buildModel()` takes,\n  produced by `stepParameterRuntime(source.stepParameterSource)`.\n\n`common/stepParameters.js` validates the pose values against the loaded\ndefinition and normalizes defaults.\n`loadSource()` uses it to populate `source.stepParameterSource`; callers then\npass `stepParameterRuntime()` into `buildModel()`.\n\n## Examples\n\nInteractive viewer/docs usage:\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource, stepParameterRuntime } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n\nconst source = await loadSource({\n  kind: \"step\",\n  glbUrl: \"/models/.part.step.glb\",\n  sourceSidecarUrl: \"/models/part.step.json\",\n  cadPath: \"models/part.step\",\n  kinematics: { drive: 180 }\n});\n\nconst model = buildModel(THREE, source, {\n  theme,\n  displayMode: \"shaded_edges\",\n  edgeSettings,\n  stepParameters: stepParameterRuntime(source.stepParameterSource)\n});\n\nconst viewport = renderModel(THREE, model, {\n  canvas,\n  hostElement: canvas.parentElement,\n  theme,\n  autoStart: true\n});\n```\n\nHeadless snapshot usage (a STEP document; `headlessRenderEntry.js` routes every other\nfamily through `common/headlessScene.js` into the same `renderModel`/`captureModel`):\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport {\n  captureModel,\n  modelOptionsForRenderJob,\n  renderJobContext,\n  renderModel\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n\nconst source = await loadSource(job);\nconst context = renderJobContext(source.meshData, job);\nconst model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));\nconst viewport = renderModel(THREE, model, { job, context });\n\ntry {\n  const result = await captureModel(viewport, { job });\n  // Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.\n} finally {\n  viewport.dispose();\n}\n```\n\n## Ownership Rules\n\n- Do not write files from shared render APIs. Return data to the owning CLI or\n  application layer.\n- Do not expose object-construction callbacks for edges. Use declarative\n  `edgeRendering`.\n- Keep STEP-only options explicitly STEP-named and reject them for non-STEP\n  sources.\n- Dispose viewports and models that you create.\n- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new\n  shared render code instead of loading assets or constructing render scenes\n  inline.\n- Draw a GLB, a mesh file or a robot with its family's scene builder, in the viewer\n  and in a snapshot alike. Never give one host its own way to draw a family:\n  `scripts/test/check-kit-boundaries.mjs` (`checkSharedSceneBuilders`) refuses it.\n","isInternal":false,"tokens":10199,"sizeBytes":43751},{"name":"resource-ownership.md","path":"packages/core/docs/resource-ownership.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/core/docs/resource-ownership.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Resource ownership and reuse\n\nHow the shared render code owns, shares, reuses and releases the expensive\nthings: component geometry, GPU buffers, BVH accelerators, tessellation\nworkers and cache reservations. Workspace resource reads and their cache\ngenerations are specified in [workspace services](workspace-resources.md).\n\nThe one-sentence laws are in [the package README](../README.md#the-laws-that-live-here);\nthis file is the mechanism each of them constrains. Nothing here may be traded\naway for speed: **reuse is an optimization, never a change in exact geometry,\npersistent cache identity or occurrence identity.**\n\n| § | Covers |\n|---|---|\n| [1](#1-shared-ownership-and-disposal) | More than one scene owner, and who disposes |\n| [2](#2-the-demand-boundary) | Selectors and raycast accelerators built only when asked for |\n| [3](#3-reuse-without-changing-geometry) | Recomposition, instancing, culling, material pass keys |\n| [4](#4-tessellation-workers) | One request per worker, pool growth, reclamation, charges |\n| [5](#5-browser-mesh-cache-admission) | Probe, admit, verify, adopt — and strict reads |\n\n## 1. Shared ownership and disposal\n\nComponent geometry and edge textures can have more than one scene owner; only\nthe last release disposes shared GPU/BVH state.\n\n- Full scene disposal includes host-reparented groups and records attached by\n  an interrupted reconciliation.\n- Cleanup retires completed ownership steps. A thrown disposal remains\n  retryable and cannot release another scene's share twice.\n- Failed initial construction cleans its partial scene before throwing, or\n  transfers the still-owned scene with an explicit cleanup error for retry.\n- Disposable resource admission never changes exact geometry or persistent\n  cache identity.\n- A scene's sibling (`createSibling()` of `lib/render/glbScene.js`,\n  `lib/render/meshScene.js`, `lib/urdf/robotScene.js`) draws over the source's\n  geometry through `lib/render/sharedGeometry.js`: `shareGeometry` gives it the\n  source's index and attribute objects, so one renderer drawing both scenes uploads\n  each buffer once, and copies only what the sibling rewrites for itself (a robot's\n  vertex colours). `releaseGeometry` lets go of the shared attributes before\n  disposing, so only what the sibling owns is freed. The source stays the owner and\n  is disposed after its siblings. A second STEP scene filled from the same published\n  mesh shares component geometry through the owner counts above.\n- `createCadWebGlRenderer` (`common/webglRenderer.js`) takes the `canvas` it draws\n  on (an `OffscreenCanvas`), so one renderer, and one upload of each resource, can\n  serve several views; disposing it and losing its context are its caller's.\n\n## 2. The demand boundary\n\nRender-only loads do not construct selector topology until it is requested,\nand viewport refinement keeps that boundary.\n\n- Unused components replace only display arrays. A component with active\n  topology replaces its selectors at the same concrete tessellation before\n  publishing new triangles.\n- Raycast accelerators may be deferred until a ray reaches a component's\n  local bounds. The first ray uses exact stock intersection; surface builds\n  enter a single worker queue in idle time and are shared by occurrences.\n- Admission covers private position/index copies, worker scratch, and the\n  result before creating an isolate. Display arrays are never transferred.\n  Each worker ends with its reservation; only serialized BVH nodes and the\n  indirect triangle permutation return. Byte accounting includes both the\n  packed BVH nodes and that indirect permutation.\n- Failed or pending builds keep stock picking. A result must match the\n  geometry's attributes, arrays, versions, groups, draw range and live\n  ownership; the last geometry release cancels queued or active work.\n- Deformation runs before the bounds test.\n- The merged edge-pick `LineSegments` (`edgePickRaycast.js`) keeps its one\n  buffer and gains a table of boxes over contiguous 32-segment runs (and\n  1,024-segment groups), built on the first edge ray (O(segments), a tenth of\n  the index buffer) and keyed by the proxy's position array. A ray runs\n  three's own segment test over only the segments whose boxes (group, run,\n  then the segment's own) it passes within the pick threshold of, widened by\n  1e-4 of the ray's reach, far above three's rounding, so the hits and their\n  order are the full scan's. A sync whose proxy only grew (every earlier\n  index and position bit-identical) keeps the built boxes and boxes only the\n  appended segments; any other change builds afresh on the next edge ray.\n\n## 3. Reuse without changing geometry\n\n### Recomposition\n\nRecomposition may take its previous result when the descriptor and component\ninputs are immutable. It shares unchanged occurrence rows and unchanged tree\nmetadata across detail swaps; changed triangle ranges, bounds, placements and\nappearance still produce the corresponding new records. Its private weak\nownership metadata never enters saved geometry or cache identity. Complete\nsame-file revisions can also seed from the prior composition.\n\nDetached occurrence snapshots detect in-place descriptor or material edits;\nonly rows proven to belong to the current composition may skip scene work. A\nstatic delta requires the same last-applied render context and no dynamic\npose, animation or clip. Changed components update their own records, while\nunchanged occurrence geometry and selectors retain their owners. Arbitrary\nmutable caller meshes keep normal reconciliation.\n\n### Selector composition\n\nAn assembly's selector runtime is composed from one part per placed occurrence\n(`buildSelectorCompositionPart`, `createSelectorRuntimeComposer` in\n`lib/selectors/runtime.js`). A part keeps its rows, its references as last\nplaced and the bundle's own proxy buffers with the placement transform — never\na placed copy of the proxy or lookup maps. The composer re-places a part's\nreferences only when its offsets move, and a composition that begins with the\nprevious one extends that result instead of rebuilding it. The result is value\nfor value the whole-runtime `composeSelectorRuntimes` result for the same\ncomponents in the same order (`runtimeComposer.test.js`). An edge's chain\nendpoints are read lazily from the bundle's unplaced positions, placed and\nrounded as the placed proxy stores them; copies of a reference carry the\naccessor rather than reading it.\n\n### Instancing and culling\n\nRepeated compatible opaque surfaces share instanced draws and retain\noccurrence identity; mirrors, transparency and deformation use explicit\nfallback paths.\n\nDetail publications retain compatible surface instance sets and their original\noccurrence slots. Only changed membership or render passes replace those sets;\nselected, hidden and deformed occurrences keep inactive slots until eligible\nagain. Transform passes reuse each mesh's matrix while observing mutable\nsource transforms and effect matrices on every update.\n\nSurface instance groups use aggregate frustum bounds, invalidated by instance\nmatrix and slot changes. Transformed component boxes and conservative parent\naffine padding keep boundary-crossing geometry eligible, including shear.\nCulling changes draw submission only; occurrence slots and picking remain\nintact. Screen-space edge instances retain their separate drawing policy.\n\n### Material pass keys\n\nMaterial pass keys reuse serialized strings only after comparing their current\nscalar values, emission state, render order and clipping planes. Direct\nmaterial and plane mutations remain observable; custom values use ordinary\nserialization. Reapplying material settings preserves unchanged shader programs\nand owned emissive colors. Colour and PBR uniforms still update on every pass;\nfeature changes, vertex-colour mode and transparency invalidate the appropriate\nprogram. Reflection intensity belongs to the shared pass key. A distinct\nnonblack emissive channel, or emission over vertex colours, uses the ordinary\nmaterial so the instance colour cannot tint that channel.\n\nClients that apply pose, selection or material changes directly to display\nrecords finish with `scene.syncSurfaceInstances()`; this uses the same\nreconciliation as a source update. Clip-only viewer updates also synchronize\nthe shared surface material.\n\n### Revision reuse\n\nCanonical geometry trees contain no surface-producer selection. A runtime view\nbinds each component's opaque surface input to a concrete immutable SURF\nobject, and render/selector reuse requires that exact binding plus the lossless\ntolerance pair and payload version. Other snapshot source scopes remain\nisolated. Placement and appearance belong to each tree's occurrence\ncomposition. Viewport L0 is explicitly coarse; L1 preserves the canonical\ndefault mesh options and key. **Changing viewport detail never changes export\ndefaults.**\n\n## 4. Tessellation workers\n\nEach tessellation worker runs one request at a time; excess requests wait on\nthe client. Render sessions lease that shared scheduler. Releasing one owner\nnever cancels another owner; the final release retires remaining work and\nworkers. Idle pressure reclamation preserves active and queued consumers.\n\n- Aborting synchronous work replaces only its worker, preserving other\n  callers. A failed worker request reports an error instead of retrying\n  expensive tessellation on the UI thread. Inline execution is reserved for\n  environments where workers cannot start.\n- A pool starts with one isolate and grows only for ready concurrent requests,\n  or when a package load finds its first components cached: it starts the\n  isolates its lanes will decode on (`prewarmSurfWorkerPool`, at most the\n  lanes' count), so their start overlaps the batch read of those bodies instead\n  of following it. Sequential viewport refinement reuses that isolate until the\n  drain becomes idle; it does not create a maximum-size pool for each component.\n- Pressure reclamation may release idle worker slots while active and queued\n  consumers keep their work. Each live slot retains its own highest completed\n  request estimate, including handled failures; reclamation or replacement\n  removes that slot's charge.\n- Memory estimates stay on the client. They do not enter worker messages or\n  cache keys, and RAM hits add no worker charge.\n\n## 5. Browser mesh-cache admission\n\nBrowser mesh-cache reads start with a bounded metadata probe. The client\nadmits the encoded object and conservative decoded size before fetching a\nbody, binds that fetch to the probed object digest and byte limit, then\nverifies the v4 header and content address before adoption.\n\nA package's open reads its cache in groups, as a snapshot does: one probe for a\nchunk of components and one TESB read for a batch of their bodies, each\ngrowing from the loader's first publish (eight components) to the server's\nbounds (256 keys, 32 MiB), in load order (`packageBatchReads.js`). A client\nwhose transport caps one reply declares a lower ceiling for a batch's bytes\n(`createCadClient({ maxBatchBytes })`, which its cache reports as\n`batchMaxBytes`): the CAD app's tunnel declares 4 MiB, the most one of its\nreplies carries. A ceiling never raises the server's bound\n(`tessBatchMaxBytes`), and a body larger than the ceiling is read alone, as\nevery body once was; that transport carries it a range at a time and hands the\nprovider the whole, which verifies its digest as it does any body's. A batch's\nframed bytes are charged to the Viewer envelope before it is read and released\nonce its last component has taken its body; each component's decode is still\nadmitted on its own before it runs, and an entry the batch could not read or\nverify is that component's strict-read miss alone. The cold components'\nsurfaces resolve up to 64 to a `/__cad/surfaces` request, each the moment its\nown row is ready.\n\nA validated warm entry carries the full surface-object provenance, so\nrendering does not need the SURF object or its derivation index to remain\npresent. `createHttpTessellationCacheProvider` takes an `origin` for hosts\nwhose cache is not on the page's own origin. TESB body groups remain bounded\nat 32 MiB; the Node export provider uses the same immutable `objects/` and\n`index/mesh/` layout as Python.\n\nA caller admitted using a cache probe can request a strict read: a missing or\ninvalid body reports a typed cache miss before tessellation starts. The viewer\nreleases that reservation and probes another cached tier or resolves the exact\nsurface under fresh cold-work admission. **Cache loss never silently turns a\ncheap decoded-mesh request into unbudgeted surface tessellation.**\n\nA client lazily owns one `createTessellationCache({provider})`, whose\n`createSession({signal})` method lends cancellable views to render sessions.\nNo module-global provider can be swapped by another client. Closing a view aborts\nits reads and prevents late results from enqueueing writes; writes already\nadmitted to the client's byte-bounded queue survive file switches. Closing the\nclient disposes the owner, aborts every view and discards remaining queued\nwrites. Viewer write-backs drain in batches with bounded concurrency: after a\nquiet interval, no later than two seconds after a batch's first entry however\nbusy the load, and at once when a batch reaches its byte bound; an entry is\nturned away only while the writer is still busy with the full batch before it.\nSo the queue holds at most two batches of encoded bodies: the one being\nwritten, at most 32 MiB (`TESS_BATCH_MAX_BYTES`, the default `maxPendingBytes`)\nplus the entry that reached the bound, and the one being collected meanwhile,\nat most 32 MiB: about 64 MiB plus one entry in all (`memoryStats()`).\nSnapshot jobs flush and dispose their own cache after their complete source is\nloaded. Decoded component meshes retain their existing page-wide\ncontent-addressed LRU; a cache view does not retain an additional geometry copy.\n\nImported STEP products without faces keep their occurrence identity. A SURF\nthat holds only wires has no loops to measure, so its scale and box come from\nits edge curves. A SURF with no faces or edges tessellates to empty arrays, a\nzero-size box at the origin and a positive minimum scale. Either way the same\nv4 validation applies. That box is only cache metadata: only triangles are\ndrawn, so composition gives an occurrence without them no bounds, and it\ncannot change the assembly's framing or hide the real parts.\n","isInternal":false,"tokens":3046,"sizeBytes":14461},{"name":"tube-deformation.md","path":"packages/core/docs/tube-deformation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/core/docs/tube-deformation.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Flexible tube animation\n\n`m.get(target).deformTube({rest, path, twistDeg=0, maxSegmentLength=1, braid?})`\nanimates a continuous tube or hollow sheath already present as a swept STEP\nbody. It deforms the original surface and its CAD edge lines in the same shared\npass used by CAD Viewer and snapshots. It does not create a replacement rope or\nchange the STEP artifact. Subsequent rigid occurrence transforms act on the\ndeformed result. The paths use assembly coordinates before those transforms.\n\n```js\nconst rest = {\n  normal: [0, 0, 1],\n  segments: [{kind: 'line', start: [0, 0, 0], end: [10, 0, 0]}]\n};\nconst bent = {\n  normal: [0, 0, 1],\n  segments: [{\n    kind: 'arc', center: [0, 5, 0], axis: [0, 0, 1],\n    start: [0, 0, 0], sweepDeg: 90\n  }]\n};\n// Inside an embedded animation clip update(t, m):\nm.get('tendon').deformTube({rest, path: bent, twistDeg: 360 * t});\n```\n\nEach path is `{normal, segments}`. `normal` is REQUIRED on both the rest and\nthe posed path: it seeds the transverse frame at the first point (projected\nperpendicular to the first tangent; later frames use parallel transport). There\nis no default — a guessed seed would flip as the first tangent turns during an\nanimation and twist the tube by 90 degrees in one frame — so an omitted or\ntangent-parallel `normal` throws. Every segment must meet the next at the same\nposition and tangent. Discontinuities and corners throw a teaching error.\n\nThe closed segment vocabulary is:\n\n- `{kind:'line', start:[x,y,z], end:[x,y,z]}`\n- `{kind:'arc', center:[x,y,z], axis:[x,y,z], start:[x,y,z], sweepDeg}`.\n  Axis is a nonzero normal. Sweep is signed, nonzero, and at most 360 degrees.\n  Start lies in the circle plane and determines its radius.\n- `{kind:'bezier', points:[p0,p1,p2,p3]}`. This is a cubic Bézier with four\n  finite vec3 control points. It uses adaptive five-point quadrature for length\n  and numerical parallel transport; stationary tangents and cusps are errors.\n\nThe runtime projects each original mesh vertex onto the analytic rest\ncenterline and retains its longitudinal coordinate and transverse offsets.\nNormalized arc length maps to the posed path; length changes therefore stretch\nor contract the visible tube. This is a display mapping, **not a tendon tension,\nspool payout, friction, collision, or constant-length solver**. An author must\nsolve those mechanics and pass the resulting centerline. Tube thickness remains\nunchanged. `twistDeg` rotates each cross-section in its transported frame; it can\nshow phase travel in an authored helical braid without moving the centerline.\nIt does not itself simulate material payout.\n\nThe source STEP tessellation is immutable. Each occurrence retains a rest\nmesh and one reusable displayed mesh; unchanged component buffers remain shared.\nStraight STEP surfaces may contain only\nend rings, so the runtime first splits their existing triangles into bands of\nat most `maxSegmentLength` mm in rest arc length. This preserves the exact rest\nsurface, interpolates attributes, preserves CAD face picking, and prevents an\nanimated bend becoming a single end-to-end chord. The default is 1 mm; values\nbelow 0.05 mm are rejected. A per-occurrence 700,000-triangle ceiling throws\ninstead of exhausting memory. Refinement is cached until the rest path or band\nlength changes. Original smooth normals are transformed by the deformation's\nlocal inverse-transpose; silhouettes and technical edges follow the surface.\n\nEvery evaluation starts from rest. Omitting `deformTube` on a later frame or\nclearing animation restores the rest shape. Shared component meshes are never\nmutated. A changed centerline can be independently inspected without a renderer:\n`evaluateAnimationClip` returns `deformations`, keyed by occurrence ID. A\ndeformation is the NUMBERS — `restSpec`, `pathSpec`, `twistDeg`,\n`maxSegmentLength`, `braid` — validated and owned, never the author's arrays and\nnever a compiled path: a morph bake fits on a 96 Hz grid, and a sample that held\nits two arc-length tables would hold hundreds of KB per tube per grid sample.\n`compileDeformation` resolves the two paths through a bounded LRU, for as long\nas the caller is posing with them; `sameTubeDeformation` and `sameTubeRestShape`\ncompare two deformations by value. `compileTubePath`, `sampleTubePath`, and\n`projectTubePath` are exported from `common/tubeDeformation.js`. Line and circle lengths/curvature are analytic;\n`minRadius` for a Bézier is a sampled diagnostic and must not substitute for an\nindependent curvature gate. As with any CAD tessellation, a finite surface mesh\napproximates the analytic posed centerline between its vertices.\n\nFor a smooth CAD core, `braid:{pitch:0.8, depth:0.02, strands:8}` adds a\nprocedural braided surface finish. Pitch and normal-relief depth are millimetres;\nstrands is an even carrier count from 2 to 64. Crossing helices, fine fiber\nlines and lighting relief use rest material coordinates and follow\n`twistDeg` as the tube deforms. This is a GPU normal/color finish, **not added\nCAD fiber bodies or collision geometry**; it remains inside the nominal core's\nrendered envelope. No image texture is fetched and no helical solids are\ncreated. The finish is scoped to that occurrence's material and disabled when\nthe effect is omitted. The original material's source color and shader hooks\nremain in force. Both shader stages — the GPU vertex transport and the braid\nfinish — install through one shared hook (`tubeMaterialShader.js`) that runs\nthem in a fixed order and hands the braid its rest coordinates through one\nvarying, so enabling the braid on a later frame than the deformation renders\nthe same as enabling both at once.\n\n## The runtime is a lazy chunk\n\n`deformTube` is the only producer of a tube deformation anywhere in this\npackage — a step module's effects carry only what an animation frame already\nput there — so a document with no `animation.source` can never reach this code\nand must never download it. `common/tubeDeformationChunk.js` is the boundary:\n`tubeDeformation.js`, `tubeGpuDeformation.js`, `tubeBraidMaterial.js` and\n`tubeMaterialShader.js` load behind one dynamic import, ~29 kB out of the CAD\nViewer's initial bundle.\n\nThe load happens at the single async door every clip must pass through:\n`compileAnimationSource` (and so `loadSourceAnimation`, and `mesh-export.mjs`)\nawaits it before any clip object exists. Declaring an animation is the gate,\nnot calling `deformTube`, because a clip may reach for a tube at any `t` and a\nframe must never fall back to the rest pose. Evaluation itself stays\nsynchronous and always sees a loaded runtime, so an animated tube renders\nexactly as it did when this was a static import.\n\nCallers outside the animation path — the scene's effect resets, the display\nedge-line pass — go through `tubeDeformation()`, which answers `null` until the\nchunk lands. That is exact rather than merely tolerant: every one of those\ncalls is a reset or a replay, and a record can only hold tube state after a\ndeformation was applied. `deformTube` cannot no-op, so it throws through\n`requireTubeDeformation` instead. Code that builds clips by hand rather than\nthrough `compileAnimationSource` — tests, mostly — awaits\n`loadTubeDeformation()` first.\n","isInternal":false,"tokens":1726,"sizeBytes":7241},{"name":"workspace-resources.md","path":"packages/core/docs/workspace-resources.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/core/docs/workspace-resources.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Workspace CAD services and resources\n\n`CadWorkspaceService` is the renderer's domain boundary. A host constructs one\nservice per workspace and lends it to every mounted CAD view. The HTTP adapter,\n`createCadClient`, implements this contract and retains its catalog, request,\ncache and worker-session lifetimes. Disposing a view releases its render session;\ndisposing the service aborts its resources and sessions.\n\nSurface resolution and editing-preview observation belong to the service.\nThe HTTP implementation owns request encoding, bounded polling, subscriber\ncancellation, replacement views and immutable store URL validation. A pending\nderivation is asked after again at 80 ms, doubling to 640 ms, then at a tenth of\nthe wait so far, at most every 5 s: a derivation stuck behind a daemon that cannot\nstart a worker is not asked many times a second until it fails. A surface\nbinding keeps its tree/view, surface input and concrete object identities;\na mismatched view, input, object or URL fails before publication. UI consumes\nthe resulting tickets and never supplies a same-origin transport fallback.\n\n`service.resources` owns JSON, text, binary and size reads, dependency resolution\nand worker tickets. Pass it as `{ resources }` to render loaders, peeks, sidecar\nloads and robot loads. Relative package assets and URDF/SDF mesh references are\nresolved by the provider. Direct glTF buffers and textures are read through it\nas well; the Three.js loader receives temporary object URLs that are revoked\nonce parsing completes. Up to six nested glTF reads run together, each limited\nto 64 MiB. Workers broker nested references back to the owning provider.\n\nA normal HTTP provider gives workers serializable URL tickets, preserving the\nexisting worker fetch/decode path, including the configured HTTP cache policy.\nConfigured headers stay on the provider origin; external nested references do\nnot inherit them. Custom fetch implementations remain host-owned. A provider with a custom fetch reads bounded\nbytes on its owning thread and transfers an owned ArrayBuffer to the worker.\nThe worker never receives a service function or detaches an admitted cache\nbuffer. SURF byte tickets are acquired only after reserving a worker slot, so\nqueued components cannot accumulate eagerly transferred buffers. Recognition reads are limited to 16 MiB. SURF display-cache hits inspect\nthe cached header before requesting a resource ticket, preserving zero-SURF-read\nwarm rendering. A miss with no SURF URL yet (a part that opened warm, before its\nsurface is resolved) fails as not ready and requests no ticket; the caller resolves\nthe surface and asks again. Aborting a worker request retains the existing scheduler and\nowner rules in [resource ownership](resource-ownership.md).\n\nEach service wraps its provider in an opaque cache generation. URL-addressed\nJSON, text, mesh and descriptor caches include this scope; completed package and\nrecognition caches do too. A fresh server-metadata read that observes a changed\nidentity token retires the generation and aborts its pending reads.\nThe provider exposes that generation signal on the main thread. Worker clients\nand recognition subscribe before acquiring tickets, so already-issued URL\ntickets cannot publish a late result after retirement. The signal itself never\ncrosses into a serializable worker message.\nReplacement services and authentication providers cannot inherit another\nservice's URL cache. Mounts borrowing the same service retain warm reuse.\nContent-addressed decoded tessellations retain their existing exact input/object,\ntolerance and payload-version keys and bounded geometry ownership.\n\nThe public low-level core loaders continue to support omitted providers for\nstandalone static HTTP callers. This is an explicit transport API default,\nusing ordinary fetch and URL-addressed caches; it is not a mutable process-wide\nworkspace provider. Shared UI requires its injected service. `loadSource` is the\nstatic/source composition boundary and creates an HTTP provider when none is\nsupplied; docs and the snapshot entrypoint compose theirs explicitly. Hosts\nrequiring custom credentials, native transfer or workspace isolation must pass\na provider consistently to both loads and peeks.\n","isInternal":false,"tokens":829,"sizeBytes":4245},{"name":"annotation.md","path":"packages/ui/docs/annotation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/annotation.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Annotation\n\nThe drawing tools — Pencil and Shapes — are the CAD viewer's own ink, with no drawing SDK behind it. A view has one\nannotation: its marks are projected into the model in 3D, so they stay where they\nwere drawn from every angle, lying on the surface they were drawn on. The STEP, GLB, mesh, robot and DXF renderers offer them\n([Pencil and Shapes](cad-renderer.md#pencil-and-shapes)).\n\n## Pieces\n\n- `annotation/session.js` — `useAnnotationSession`: the annotation's ink, what the tools\n  put down next (the tool of the strip tool in hand, `pencil` — the pen, the highlighter or the eraser — and `shape` — the line first, the arrow, rectangle, ellipse or text — each kept while the other is up; `style`, the colour, stroke width and\n  placement — `surface`, the default, or `plane` — of the tool in hand, kept per drawing kind in `styles`: `pen`, `highlighter` (yellow\n  `#ffe600`; the others neon red) and `shapes`, which all five shapes share; the eraser has none) and the selection. Each add, erase, restyle of the selection\n  (`restyle`) or fill of a closed shape (`fill`, joining its group) is one step\n  in the file view's history (`renderers/kit/shell/useViewerHistory.js`, 200\n  steps, shared with the renderer's own tools; without one the session keeps its\n  own). Choosing what is drawn next never touches ink already down.\n  `clear()` is a step; `discard()` drops the ink and takes its steps out of the\n  history, for a model that changed under it. The session also keeps the views ink was drawn\n  in (`views`, \"view 1\", \"view 2\"…), as labels for the agent: `noteView(view)` names the one a mark goes\n  down in — a view drawn in before that is the same (`annotation/views.js`'s\n  `sameView`: less than 2° of turn, 5% of zoom or pan, nothing else changed),\n  or a new one.\n- `annotation/views.js` — a view as a prompt carries it, in a snapshot job's own\n  words: the camera (always orthographic), Clip when on, the one motion on\n  screen (Explode, or the pose off its default as `kinematics` or\n  `jointValues`), the parts hidden and isolated, and the picture's size. Never\n  how it looks.\n- `annotation/tools.js` — the two strip tools that draw and their tools: Pencil (`freedraw`, `highlighter`,\n  `eraser`) and Shapes (`line`, `arrow`, `rectangle`, `ellipse`, `text`); `toolFamily(tool)`, `styleSlot(tool)` (the style slot a tool draws with; none for the eraser).\n- `annotation/toolbar.jsx` — `AnnotationToolbar`, the sub-toolbar both strip tools share: one row (at every width; a phone's\n  wraps where it has no room) — the tools of the strip tool that is up, a button each, the one in hand pressed (Pencil's Pen,\n  Highlighter, Eraser; Shapes' Rectangle, Line, Arrow, Ellipse, Text); a rule; Color, Stroke width and Placement (On\n  surface, On plane), dropdowns of the tool in hand's own style (disabled under the Eraser), Stroke width's glyph a straight line drawn at the weight (four, 1–6px, unnamed in its menu; Color's and its menus have no heading), then Clear;\n  Undo and Redo are ⌘Z and ⇧⌘Z, with no buttons. A flat view (a DXF) passes `placements={false}`: its ink has nowhere to lie but the drawing.\n  `annotationColors` offers\n  neon swatches and a neutral that reads against the theme: white in dark mode,\n  near-black in light mode. A native color picker adds a custom color.\n  `AnnotationStylePickers` also restyles the selection's ink.\n- `renderers/kit/tools/annotate/AnnotationSelectionBar.jsx` — the bar over the ink Select\n  picked: its colour and weight, Fill for a closed shape (seeded\n  inside its outline as it falls on the screen, `annotation/geometry.js`'s\n  `interiorPoint` and `fillMarkAt`, and projected where the shape lies: On plane on the\n  shape's own plane if its points are coplanar (a fitted plane), else On surface) and\n  Delete.\n- `annotation/geometry.js` — each tool's mark in screen space:\n  - The highlighter (`highlighterMark`) is the pen's smoothed line at one broad width — five times the stroke width\n    (`HIGHLIGHTER_WIDTH_FACTOR`) — 35% opaque (`HIGHLIGHTER_OPACITY`), round-ended, with no `widths`: an ordinary\n    stroke mark (`width`, `opacity`) that no new field or kind describes. `strokeWidthAt` and `strokeSettingOf` convert\n    between a stroke's width and the setting (1, 2, 4) for restyling. Translucent strokes are drawn once-only\n    in the model (`core/common/annotation.js`: a stencil reference per stroke), so its overlapping segments do not darken.\n  - The pen is smoothed as it is drawn, and each point's width comes from its\n    pressure (a stylus's own, or simulated from speed with a mouse), using\n    `perfect-freehand`.\n  - Rectangles get rounded corners.\n  - A fill finds the area inside a shape with\n    `@text-to-cad/core/lib/annotation/fillRegion.js`.\n- `renderers/kit/tools/annotate/AnnotationOverlay.jsx` — the surface over the viewport\n  while Pencil or Shapes is up. It turns every gesture into marks, previews a mark on its\n  own canvas as it is made, and puts it into the model when the gesture ends.\n  Text is written where the surface is pressed: Enter, or a press elsewhere,\n  puts it down as a text mark; Shift+Enter makes a new line; Escape drops it. The\n  surface stops the press's mousedown, which would otherwise take the keyboard\n  back from the text it just opened.\n  Each mark names the view it was put down in, and is projected onto the surface it starts on.\n  The drawing tools have no Select of their own:\n  the viewer's Select picks ink (`RendererShell.jsx`, through the viewport's\n  `annotationAt`), Shift adding to the pick, and Delete and Escape act on it (here too).\n  The camera stays the person's between strokes: the wheel zooms, a right or\n  middle drag orbits, and while Space is held the pointer is the view's.\n- `mountAnnotations(runtime).update(items, selected, hovered)` haloes the ink Select\n  picked, a wider line in the selection's blue behind it, and, fainter, the ink\n  its pointer rests on (the shell's `hoveredAnnotation`, the marks drawn with it too). Ink draws after the model and its\n  highlights (`ANNOTATION_RENDER_ORDER`), so a lit part never paints over it. Nothing else is drawn: no\n  pane, no label. The ink's box (`runtime.annotationBounds`) widens the camera's depth range.\n- `renderers/kit/tools/annotate/annotation3d.js` — projects a mark into the model.\n  - Every point stays on the ray through where it was drawn, so from that view the ink is\n    exactly what was drawn. **Ink lies on the surface it is drawn on.** Every mark has a\n    **level**: the plane of the surface it starts on (a stroke's first point, a text mark's anchor\n    corner, a fill's first outline point), found by `levelOf(runtime, start, size)`. A flat face's\n    level is its own plane; a curved face's, its tangent plane at the start point (the geometry's\n    smooth normal there). The normal is the surface's, **not the view direction**: from any\n    angle, a mark started on a plate's top has the plate's top plane as its level. No sheet, no\n    pane: nothing is made per view, and nothing is held between marks.\n  - **Placement** says what a point does, per mark at draw time. *On surface* (the default): each\n    point drops onto the visible surface under it (a stroke follows a curved face and steps across\n    faces); a point with nothing under it (past the model's edge, over a through-hole) takes the\n    level: its ray met with the level plane, so a stroke that trails off a face carries on at that\n    face's level. *On plane*: every point is its ray met with the level plane — flat across a\n    curved face, staying on the tangent plane where it started, and carried off the surface at\n    the same level. Both modes agree off the surface. A stroke's line is first split into pieces\n    of a few pixels (`subdivideStroke`) in both, so a two-point line reads the surface all along\n    it; the pen's widths are split with it. In On surface a point takes the nearest depth within\n    its own reach — half the stroke's width and a pixel, never a neighbour's, so a slanted face\n    does not lift the ink off it; a fill point by point. Every point is then two screen pixels toward\n    the eye, text eight. Text is a card facing the view, at the level's depth under its anchor\n    corner, in both modes. Ink already down never moves with the setting.\n  - **The level, when the start is over nothing** (in order):\n    1. the surface under the start point (a ray through it);\n    2. the **visible surface nearest to the start on the screen**: in a depth render of the parts\n       over the window (read back at no more than 256 pixels on the long side), the covered pixel\n       with the least screen distance to the start, found by searching outward in rings of pixels\n       from where the start falls (the nearer of two as far wins), then ray-cast through its middle\n       for the surface point and normal there;\n    3. nothing drawn in the window, but a model: the drawn surface **nearest to the window on the\n       screen** — the same render over a region round the window, large enough to hold the model's box\n       on screen (the window's size on each side where part of the box is behind the eye), at low\n       resolution; the covered pixel with the least screen distance to the window rect;\n    4. no model drawn: the plane through the origin facing the view.\n\n    \"Drawn\" is what the depth render sees: hidden parts and what Clip cuts away do not count.\n    Clip's cap faces are not part meshes (stencil fills, `sectionCaps.js`), so they are not in\n    the render: through a cut the level is the surface seen inside. Without a GPU float render the\n    same reads come from rays, at a far lower resolution. The depth renders are made only when a\n    level needs them, once for all the marks put down together (`projectAnnotations`).\n  - **Grazing guard:** a level whose plane is nearly edge-on to the view (|normal · view\n    direction| below 0.08) is replaced by the plane facing the view through the same point, else rays\n    would stretch the mark along the plane.\n  - `projectAnnotations(runtime, marks, size, { placement, topologyAt, level })` projects marks,\n    finding each one's level inside; `level` ({ point, normal }) gives all of them one instead (a fill\n    of a flat shape lies on the shape's plane).\n  - What the model hides of the ink is drawn faint through it (core's `ANNOTATION_HIDDEN_OPACITY`,\n    on the viewer and in snapshots), so a profile drawn through a part's middle is seen.\n  - Every mark is in model coordinates: it stays where it was drawn whatever\n    Explode, Position or hiding a part does to the model. Clip cuts it.\n    `touches` says what it was drawn over, most first, the same for either placement: the faces and edges the\n    renderer names under it (`ref`, a prompt reference, from the shell's\n    `inkTouches`; the STEP renderer's reads them whatever Select's filter picks),\n    each with its part; without it, the parts.\n  - The projected ink is plain data (`AnnotationItem`): an annotation mark\n    (`@text-to-cad/core/common/annotation.js`), kept across scene rebuilds.\n    `mountAnnotations` draws it in a viewer runtime and `update` draws only the\n    marks a change adds; the objects are core's, the ones a snapshot draws an\n    annotation with. The preview\n    holds a finished mark until the scene has drawn it. `annotationDocumentOf` writes the\n    annotation as an annotation file. The file format is unchanged: marks are 3D points; the level is the viewer's own.\n  - A double-click under Select (Look at, `kit/camera/lookAt.js`) is navigation only; it sets\n    no plane for ink.\n\n## Lifetime and capture\n\nThe live annotation is session-only: it is never written to tab or file state. It\noutlives the drawing tools being put down, because the ink stays in the model and the next\none adds to it. It goes when the person clears it, or when the model it was\ndrawn on changes.\n\n⌘C / Ctrl+C while Pencil or Shapes is up copies the view as it is on screen, ink\nincluded, to the clipboard as a PNG.\n\nQuick Edit captures the current view and distinct views with remaining ink when\nCopy Prompt, Queue or Send is pressed (`host.promptFiles`). The snapshot packet\nincludes shared annotation and measurements: up to 1 KiB of compact UTF-8 JSON\ntravels inline, and larger packets are saved to a temporary `.snapshot.json`.\nNo images are rendered at handoff. Save inline JSON to a file before running\n`cadgen snapshot --job <file>` to render those views over the model or an edit of it.\nThe Annotation chip does not copy references or save individual files.\nQueue and Send clear the annotation with the note; Copy Prompt keeps it.\n","isInternal":false,"tokens":3129,"sizeBytes":12669},{"name":"cad-renderer.md","path":"packages/ui/docs/cad-renderer.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/cad-renderer.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# CAD renderer\n\nThe CAD viewer is the viewport, the floating tool strip and the tool in hand's\nsub-toolbar under it, the file sidebar, reference interactions, measurement and annotation\ntools, animation, loading artwork and alerts, in `@text-to-cad/ui`. The rules of its\nchrome — which tools a file has, how they take and give back the pointer, the file sidebar,\nPreview, the keyboard — are the\n[design system](settings-ui.md); this document is the mechanism behind them.\n\nThere is one renderer per file family over a shared, format-blind [kit](#kit) and\n[shell](#shell). The STEP renderer is under `src/renderers/step`, on the kit like\nevery other family: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`, which\nruns `useRendererShell` exactly as the other renderers do and composes STEP's own\nmodules around it (see the end of [Shell](#shell)). Applications use the registrations and the shared\n`FileViewer`; they do not import another application's source.\n\n## Kit\n\n`src/renderers/kit` is the format-blind half of the viewer: small modules a\nrenderer composes, none of which asks what it is showing. There is one renderer\nper file family, each a vertical slice over the kit: `src/renderers/dxf`\n([DXF renderer](#dxf-renderer)), `src/renderers/glb` ([GLB renderer](#glb-renderer)),\n`src/renderers/mesh` (STL and 3MF, [Mesh renderer](#mesh-renderer)),\n`src/renderers/robot` (URDF, SRDF and SDF, [Robot renderer](#robot-renderer)) and\n`src/renderers/step` (STEP and STP, [STEP renderer](#step-and-source-separation)). The kit imports itself, shared UI\n(`primitives`, `lib`, `drawing`) and the format-blind half of `@text-to-cad/core`\n(`lib/viewer/*`, `lib/perspective.js`, `common/viewSettings.js`,\n`common/sceneSettings.js`, the Render studio); a renderer imports the kit, never\nthe reverse.\n\n| folder | what it is |\n| --- | --- |\n| `viewport/` | `useViewerRuntime` (three.js renderer lifecycle, on-demand render loop and `requestRender`, resize and device-pixel-ratio caps, context loss, keyboard orbit, `setSuspended`, teardown), `gpuDevice` (the page's one WebGL context, shared by every viewport: see [the GPU device](#the-gpu-device)), `previewViewScene` (Preview's view of the file's Render settings: no clip or explode, one scene-quality tier up, `previewSceneQuality`), `framePresentation`, `viewportBuffer`, `renderDepthPolicy`, `sceneObjects` (`disposeSceneObject`), `thumbnail`, DOM helpers. The scene in the viewport is its owner's: teardown calls the injected `disposeScene(runtime)` and `disposeStudio(runtime)` and disposes its lights' shadow maps; a runtime with a renderer of its own (no device) then loses its WebGL context (`forceContextLoss`), since three's shared textures keep every renderer that drew them reachable and only a lost context gives its GPU memory back, and one on the device lets go of its view and never ends the device's context. A torn-down runtime draws no further frame. The orbit controls let go of the page in a layout cleanup, before React detaches the canvas: they listen on its root node, which is no longer the document once it is detached. |\n| `camera/` | `runtimeCamera` (zoom percent against the authored framing, projection and lens sync, perspective snapshots, eased transitions, fit-to-bounds, recentre), `useViewportCamera` (that behaviour bound to a mounted viewport: the perspective kept by a mounted view, the reset that Zoom to fit and the live `resetCamera` share, view-cube presets, which turn the camera and keep its zoom and target), `viewportCameraKit` and `viewportCameraFit`, `orbitControls`, `zoomPivotReanchor` (a wheel step first moves the pivot's depth onto the surface under the cursor, and a perspective pan is scaled by that surface's depth, so Render pans and zooms what the cursor is on at Solid's rate), `zoomSpeeds`, `cameraLens`, `ViewPlaneControl` (view cube). |\n| `look/` | `stageEffects` (lighting rig scaled to the model, floor, glow and shadow catcher, grid and origin axes), the Render studio boundary (`renderStudioChunk`, `studioEnvironmentCache` and its worker). `chromeBackdrop` and `useChromeBackdropColor` (the frame colour around a scene). The surface LOOK is data the viewport resolves and a scene applies to its own materials: `@text-to-cad/core/lib/viewer/surfaceLook.js` (`createSurfaceLook(THREE, root).apply(look)`) does it for any authored material tree. The viewport resolves it with core's `resolveSceneSurfaceLook` (`common/sceneSettings.js`), the resolver the snapshot CLI dresses the same scenes with. |\n| `view-settings/` | The settings model and store (`viewSettingsStore`, `useViewSettings`, `viewerDisplaySettings`, `renderState`), applying a change to a viewport (`useAppliedViewSettings`, `viewUpdateCoordinator`, `viewUpdateGate`, `viewUpdatePlan`), the content of Display's dropdown (`DisplayModeOptions`: the modes and the Grid switch's icons; `DisplayPopover` draws it), and Preview's settings form (`PreviewRenderSettings`). |\n| `tools/` | `FloatingToolBar` (the dumb strip: the tools it is handed, in groups parted by one thin rule, then, only where the host has no navbar, the view's controls, which end the last section the tools have with no rule of their own; after a rule only where the tools are a single group), `SubToolbar` (the tool in hand's one row under the strip, its groups parted by `SubToolbarSeparator`; `SLIDER_SUB_TOOLBAR_CLASS`, the one width of a slider tool's row), `toolModes` (the tool-mode state machine), `ToolModeMenu` (`ToolSettingsMenu`: a tool's settings dropdown, from a chevron right after its button on the strip, or a sliders button in a panel's heading), `ToolPopover` (an ordinary dropdown from its button, on a `side` and `align`ed start or end: the Animation section's Playback settings), `FileSidebar` (the file's sections, whatever the tool: a tile each at the top-left, a tile opening its section in a panel under them, one at a time, sized from its bottom-right grip; on a phone one tile, `phone`, opening one panel with the sections as tabs and its one X; Preview offers its Position and Animation alone, `sections`, its tiles `bare`), `ToolStack` (the bounded column under the strip and sub-toolbar for a renderer's own `toolPanels`, which scrolls only when what cannot give way still does not fit), `ToolPanel` (one panel of the stack, or one section of the sidebar: `fit` says how it gives way when the viewer is short, `resizable` makes a stack panel the person's to size by the grip in its bottom-right corner alone, moving only it, and a panel with a heading can fold to it; its `footer`, a `ToolPanelFooterButton`, is a full-row action under the body that never scrolls), `ResizeGrip` (the one resize grip of a box over the viewport, in a bottom corner: the sidebar's and a resizable panel's at the bottom-right, Quick Edit's at its bottom-left), `toolStackLayout` (every panel's width, each sidebar section's and the resizable panels' sizes, and the folded stack panels: defaults, bounds and their stored record), and the format-blind tools: `annotate/` (`AnnotationOverlay`, the surface the drawing tools' gestures land on; `annotation3d`, its ink projected into the model; `AnnotationSelectionBar`, the bar of the annotations Select picked), `PreviewChrome` (Preview's chrome: the tools view's controls hidden and inert while it is up, and its own corner, `end`, with its settings panel under it, `panel`), `PlaybackMenu` (the Playback settings — Speed, Loop, Autoplay — and `ORBIT_SPEEDS`, Preview's Orbit speeds), `playbar/` (`AnimationSection`, the file sidebar's Animation section: Reset in its heading, the routine where there are several, Play/Pause, the scrubber, Playback settings; `AnimationTimeControl`, the scrubber, and `animationControlsHaveContent`; `animationClock`, `usePlaybackFrames`, `playbackPreferences`: the file's playback settings — Orbit's speed, Autoplay, and the chosen Speed and Loop), `pose/` (the handle overlay, canvas, drag mathematics), `select/` (`SelectFilterMenu`, Select's selection filter on its chevron; `selectFilter`, its pure toggles; `usePointerPick`: taps and hover through a scene's own `pick`, each with where the pointer was; `useSceneSelection` and `sceneReferences`: the parts a scene's pick names, selected, lit and handed to Quick Edit as references), `measure/` (Measure, for any 3D view: `useMeasureSession`, the session; `useMeasureOverlay`, its drawing; `MeasurementCard`; `measurementChips`, its chips and its row under the strip (`MeasurementsBar`); `measureSelection`, Select's press and hover on a measurement; `SceneMeasure`, two points on a scene's surface, and the model's size row), `model-effects/` (Explode and Clip, for any scene that opts in: `ModelTools`, their strip tools and slider rows; `useSceneEffects`, the viewport applying them to the scene; `motionExclusion`, one motion at a time), `sceneTools` (the tools a scene's renderer composes around the shell: the tool in hand, Pencil and Shapes, Measure and the model effects), `quick-edit/` (`QuickEdit`, the note to the agent, and `quickEditPrompt`, the context it builds and the text it copies). Screenshot capture is `@text-to-cad/core/lib/viewer/screenshotCapture.js`. |\n| `inspector/` | `FileSheet` and its row and section primitives, `modelTreeSearch` (`useTreeSearch`, the ranked flat search every tree shares), `VirtualRows` (a long tree's rows, windowed), `referenceRows` (`InfoRow`, `MonoValue`, `CoordValue`), `kinematicsControls` (the `Pose` row that heads every Position section: Default, the named poses, Custom). The tree row and filter box are `primitives/tree-row` and `primitives/tree-filter`. |\n| `status/` | `LoadingIndicator` and `ViewerLoadingOverlay`, `ViewerAlertCard` (the card over the viewport for every alert, and `useAlertDismissal`: a card put away, and its icon in the navbar that brings it back) and `reportIssue` (`alertIssueUrl`: its Report Issue's new issue), `ViewUpdateStatus`, `loadingState` (`viewerLoadingState`), `loadAlerts` (`failureAlert`, `noGeometryAlert`). |\n| `shell/` | The host glue every renderer needs that is not about its scene: see [Shell](#shell). |\n\n**What a view opts into.** Display's settings and `resolveViewSettings` take explicit\nlists, `features: { modes, settings }` (`ViewFeatures` in\n`@text-to-cad/core/common/viewSettings.js`): the modes it lists and the settings\nits menus offer (`VIEW_SETTING_IDS`). A setting left out does not affect the\nscene, and saved settings are never rewritten. `viewFeaturesForKind(kind, { parts })`\nis the one rule of which list a file gets: `ALL_VIEW_FEATURES` for a STEP;\n`PARTED_MESH_VIEW_FEATURES` (Solid, Clip and Explode) for a GLB of several rigid nodes\nthat draw; `MESH_VIEW_FEATURES` (Solid and Clip) for any other 3D file; and\n`EDGELESS_VIEW_FEATURES` (no Edges, Clip or Explode) for a drawing. Render is no mode. Every\nrenderer hands its list to the shell, which configures the view-settings store\n(`configure({ features })`) and Display's settings from it, and to its Explode and Clip\ntools. The headless renderer (`renderMeshScene.js`) resolves a job of each family under the\nsame rule.\n\n**Tools.** The strip draws the list it is handed: `{ id, label, icon, active,\ndisabled, onSelect, description?, menu? }` — a press is a tool's only action; a tool with\nsettings has them as `menu`, a chevron right after its button (Select's filter, the only\none), and what it can be set to while it is up is its sub-toolbar. Display is not a tool:\nits settings are a dropdown from its button at the navbar's right end, before Preview\n(`kit/shell/DisplayPopover.jsx`), a 3D view's alone. A renderer\nbuilds its own list from `shell.tools.own(...)`, adding `shell.tools.drawing` (Pencil and Shapes) where it offers\nthem, declared in its `toolModes` as two modes (`pencil`, `shapes`) (a press on the tool in hand does nothing); STEP's is in `step/StepSurface.jsx`.\nRoutines play from the file sidebar's Animation section, which the shell adds for any file with\nroutines (`kit/tools/playbar/AnimationSection.jsx`), in the tools view and Preview alike. Preview is not a tool: its button, after Display at the navbar's right end, enters it. Nor is Quick Edit: it is a box at the top-right, there while something is picked or annotated. `createToolModes({ defaultMode, modes })`\nanswers what a press does (`next`), what a saved tab may record (`persisted`) and\nwhich tool a file opens in (`restore`); the STEP declaration is\n`CAD_TOOL_MODES` in `workbench/constants.js`. The scrubber follows the clock on\nthe runtime it is handed (`runtime.clock`, an `AnimationClock`); Position's knobs\noverlay (`tools/pose/`), Select's while its filter ticks Position, takes a plain handle list, as a prop or (for an\nowner that poses its model outside React) as a ref whose list it replaces per pose.\n\n**The scene contract** (core's `lib/viewer/sceneContract.js`, a JSDoc typedef that `kit/scene.js`\nre-exports, because the snapshot CLI holds the same scenes): `{ object3D, bounds,\nrestBounds?, dispose(), setSurfaceLook?(look), setShadowReception?(receives),\nkeepsAuthoredFinish?, complete?, pick?(ray), placedObjects?() }`, and for a family's scene\nthe tools' half: `units` (the file's unit against the scene's coordinates, core's\n`sceneUnits.js`), `setClipPlanes(planes)`, `explodeParts` and `setExplode(amount)` (a GLB),\nand, for a scene whose parts are picked by an id of the file's own, `hasPart(id)`,\n`describePart(id)` and `setHighlight({ hovered, selected })`.\nThe viewport adopts `object3D`, frames `restBounds`, lights and floors `bounds`,\nand hands over the surface look the Display settings resolved\n(`{ materialSettings, authored, surface: { style, opacity } }`); it only ever\ndetaches a scene, whose owner disposes it. `buildModel`\n(`@text-to-cad/core/common/cadScene.js`) exposes `object3D`, `bounds`, `restBounds`\nand `dispose()`; the GLB and mesh renderers' scenes implement all of it but `pick`,\nand the robot renderer's all of it. A family's `pick(ray)` answers the first surface Clip has\nnot cut away, with the point it hit (`point`, for Measure). `pick(ray)` is what `usePointerPick` calls: the\nhook owns the pointer (which press is a tap, one hover pick per frame, the\ncursor), the scene says what is under the ray, and its renderer says what a hit\nmeans. A tap acts at once: nothing waits to tell it from a double-click.\n`placedObjects()` is for the near/far fit alone: a camera inside a mostly empty\naggregate box can still be well outside everything visible in it, so a scene that\nplaced many things says so and is fitted on them instead of on its whole box.\nShadow reception is the viewport's for every mesh of a scene, unless the scene\nimplements `setShadowReception`: a scene with unlit or see-through surfaces is told\nthe setting and applies its own rule (a STEP's watch crystal never takes a shadow).\nA mesh names where \"Color by part\" deals it a palette colour in\n`userData.cadFillIndex` (otherwise meshes take the palette in traversal order),\nand a material with no source colour says so with `userData.cadSourceColor = false`.\n\n**The rule and its check.** Kit code names no file format, no file-kind switch\nand no STEP-assembly concept (topology, selectors, display records, explode,\nsection clipping), in code or in comments; only `view-settings/` may name the\nClip and Explode sections a view opts into, and `tools/model-effects/` the two effects\nit applies to whatever scene opts in. `npm run check:boundaries` runs\n`scripts/test/check-kit-boundaries.mjs`, which scans every kit source for imports\nof a renderer or of a file-family core module and for those words, against a\nshort allowlist that carries a reason per line and fails when an entry goes\nstale. The same script holds every split-out renderer to its slice\n(`RENDERER_SLICES`): a renderer imports the kit, `renderers/workspace` (the\nbackend connection a file is prepared against, the host's viewer preferences and\ncommand types, and `useWorkspaceDocument`), shared UI and core, and never another\nrenderer. It holds the host's file viewer (`src/file-viewer`) to the mirror of\nthat rule: the file viewer mounts a renderer through the registry and imports\nnone itself, so no host pays for a family it is not showing. The\nunbound-identifier test (`src/renderers/unboundIdentifiers.test.js`) scans the\nwhole renderer tree, so a renamed or added slice is covered the moment it\nexists. The same script's `checkSharedSceneBuilders` holds the rule of the next\nsection: each family's builder, loader, opening pose and look are imported by the\nrenderer AND by the snapshot's `common/headlessScene.js` from one core module, are\ndefined nowhere else, and no module on the snapshot's render path imports a mesh\nflattener.\n\n### One scene builder per family: the viewer and the snapshot CLI\n\n`cadgen snapshot` of a GLB, an STL, a 3MF or a robot description draws the scene the\nviewer draws, because both call the same builder. A family's scene construction lives in\n`@text-to-cad/core` (no React, no DOM beyond three.js); its renderer here keeps the loading\nstate, the tools and the panels, and the snapshot page (`common/headlessScene.js`, routed\nfrom `common/headlessRenderEntry.js`) keeps only what a still has and a viewport does\nnot: the camera fit, the studio set up per output, and PNG encoding.\n\n| family | builder (core) | viewer caller | snapshot caller |\n| --- | --- | --- | --- |\n| GLB | `lib/render/glbScene.js` `createGlbScene`, over `loadRenderGlbDocument` | `glb/useGlbScene.js` | `GLB_FAMILY` |\n| STL, 3MF | `lib/render/meshScene.js` `buildMeshScene`, over `loadRenderMeshByUrl` | `mesh/useMeshScene.js` | `MESH_FAMILY` |\n| URDF, SRDF, SDF | `lib/urdf/robotScene.js` `createRobotScene` over `robotParts.js`, loaded by `lib/urdf/loadRobot.js` | `robot/RobotRenderer.jsx`, `robot/useRobotDocument.js` | `ROBOT_FAMILY` |\n| STEP | `common/cadScene.js` `buildModel` | `step/scene/stepScene.js` | `headlessRenderEntry.js` |\n\nBoth hosts dress a scene the same way: the look from `resolveSceneSurfaceLook`, shadows\nwhile the lighting is on, `keepsAuthoredFinish` for Inspect's reflection environment, and\nthe ground sized from `restBounds`. Both cut and explode it the same way: Clip through\ncore's `lib/viewer/sceneClip.js` (the plane in the box the camera frames, `at` in the\nfile's unit, the cut capped as a STEP's is), Explode through the scene's `setExplode`\n(`lib/viewer/sceneExplode.js`, a STEP's layout over the file's tree). A robot's parts a\nQuick Edit's view hid come back in its job's `selection.hide`. A robot opens at `robotOpeningPose`\n(`lib/urdf/motion.js`: every joint's default, then an SRDF's `home` state) and a\nsnapshot's `--joint-values` go on top through the same joint matrices. What a snapshot\ncannot express, it refuses rather than approximates: a GLB's clips play in the viewer's\nAnimation section and have no snapshot flag. The pieces are pinned by `common/headlessScene.test.js`\n(core), `kit/view-settings/renderState.test.js` (one look from either route) and\n`tests/python/packages/cadgen/test_snapshot_family_scenes.py` (real snapshots, read as\npixels).\n\n### Shell\n\n`kit/shell` is what a file-family renderer needs from its host that is not about\nits scene. A renderer loads its document, builds its scene (`kit/scene.js`) and\ncalls one hook; the shell owns the rest.\n\n| module | what it is |\n| --- | --- |\n| `useRendererShell.js` | The hook. Per-file state through the host, the Display settings store and the content of Display's dropdown (`shell.display`), tool modes (Pencil and Shapes are the strip tools the shell itself owns, `shell.tools.drawing`; Display, Preview and its controls are the frame's; `toolModes` is omitted altogether by a renderer with no tools of its own), the file sidebar's and tool stack's layout (a viewer preference: `services.preferences.toolStack` — each sidebar section's size, the resizable panels' sizes, the folded stack panels — changed by one patch per gesture, `frame.changeToolStack`), prompt snapshots (the host's `captureRequest`), the clipboard screenshot (the drawing tools' Copy), what Quick Edit takes from the view (its file, how a copied prompt spells a path, the view as a picture, the viewer's Escape), the one Preview state (`shell.previewing` / `setPreviewing`, from `usePreviewState`; every way in and out goes through `setPreviewing`, which mounts Preview's view or takes it away and touches nothing of the editor's), Preview's scene (`frame.previewDesiredScene`: `previewViewScene(store.renderScene())`, Render over the file's settings as Preview draws them) and its viewport (`frame.previewViewRef`), Preview's Orbit (`shell.orbiting`, transient, off at every entry and exit), Render's studio code and environment prepared 600 ms after the model is on screen at Preview's quality, into the GPU device's cache Preview draws from (`previewPrepare`; it changes nothing the editor draws; not on a software renderer, the viewport's `softwareRendering`, where Preview prepares as it is entered), and Autoplay (`shell.autoplay`), alerts, shortcuts, and the live command surface (`setCamera`, `capture` and `resetCamera` act on the view on screen: Preview's while it is open). It owns no zoom control: the shell has none. |\n| `RendererShell.jsx` | The frame: viewport box; one centred column at the top centre (`TOOLBAR_POSITION`, `data-cad-tool-groups`) holding the tool strip (the renderer's tools; the view's controls — Display, the active mode's sphere, whose dropdown is `DisplayPopover`, the modes and the Grid switch, then Preview, a play triangle, for a `previewable` view — are the navbar's, portalled into `view.navbarSlot`, and end the strip only where there is no navbar), under it the tool in hand's sub-toolbar (the drawing tools' `AnnotationToolbar` while Pencil or Shapes is up, else the renderer's `subToolbar`: Explode's, Clip's), the update status, and the tool stack (`ToolStack`: the renderer's `toolPanels`; the column stops above the cube's line, `VIEWPORT_STACK_BOTTOM`); the file sidebar at the top-left (`FileSidebar` over the renderer's `sidebar`, `SIDEBAR_POSITION`: its tiles 8px in from the top and left, the open section's panel under them down to 8px off the bottom; on a phone one tile at the left of the strip's line, the strip padded clear of it, `TOOLBAR_POSITION_PHONE_SIDEBAR`, and one tabbed panel down to Quick Edit's peek, `SIDEBAR_POSITION_PHONE`); Quick Edit at the top-right; the view cube in the bottom-right corner; Preview's controls (Preview settings and Exit preview in the navbar slot, or, with no navbar, at the view's top-right, `PreviewChrome`'s `end`; the settings, a toggle whose panel docks at the view's top-right, `panel` (`PreviewSettings.jsx`: Orbit and its speed, then Preview's own settings, `frame.previewSettings`), then Exit preview, an X — and the file sidebar's Position and Animation alone, bare, at the top-left, `PREVIEW_SECTIONS`; nothing under the model); Preview's view (`PreviewView.jsx`, `data-preview-view`), mounted over the editor's viewport box while `shell.previewing` and unmounted as it closes, the editor's viewport `suspended` under it; the cover that answers the press (\"Preparing preview…\", `data-preview-cover`) until Preview's view has presented its first frame (`onPresented`), no finer detail is on its way (quality neither preview nor refining) and its picture has converged (`pictureProgress`) — lifting at once on a view the shell set moving (Orbit, a routine playing), never on a picture that detail started again (`previewCover.js`); at five seconds it stops waiting for detail and lifts on the picture converged, a second later at the latest, with no cover on the way out; the Animation section after the renderer's sidebar sections, for a file with routines, and the file's chosen Speed and Loop applied to its routine; the loading overlay; and the alert card. While the model loads, and once a load has failed in a way the model did not survive (an alert that cannot be put away), it draws none of its controls: only the load, or the card saying why, with the update status at the top centre in the strip's row; in a compact host (`appearance.compact`), none at all: the model alone. Nothing sits at the bottom centre. It takes `studio`, the renderer's own Preview while it is open: `{ scene, overlay(viewport, drawn), animation, runtimeLifecycle, onCameraSettled }` — the scene Preview draws, built by the family's own builder over the editor's geometry, what drives it from inside Preview's viewport (handed the Render scene it draws), Preview's routine (the Animation section's in Preview; Autoplay starts it on entry), and Preview viewport's lifecycle and settle. It takes `references` (what is selected, in the prompt grammar: what Quick Edit attaches and counts), `referenceChips` (how Quick Edit shows them) and `copySelection` (what ⌘C / Ctrl+C copies while the renderer's own tool is up and something is selected; null when nothing is), and, under Select, `onSelectPress`, `onSelectHover` and `selectsAnnotations` (whether Select's filter picks the annotations), and `inkTouches` (what the model is under a point of the view, `(clientX, clientY) => [{ part, ref? }]`: what the drawing tools record a mark as touching). One DOM structure (`data-slot=\"cad-file-view\"`, `data-cad-surface`, `data-cad-scene-backdrop`, `data-cad-toolbar`, `data-view-controls`, `data-cad-sub-toolbar`, `data-cad-file-sidebar`, `data-cad-tool-stack`, `data-tool-panel`) for every renderer. `frameProvider` wraps the WHOLE frame in the renderer's own context — the sidebar and the tool stack as well as the viewport, because they read it — and `onCanvasPointerDown` is a press that landed on the model, for a renderer with something to put down when the person reaches for it. The frame focuses itself on such a press either way. |\n| `ShellViewport.jsx` | The three.js viewport around ONE kit scene: `useViewerRuntime`, `useViewportCamera`, the look (rig or studio, environment, background, floor, grid, axes), the drawing tools' surface and the annotations' ink in the model, the view cube (bottom-right, `VIEWPORT_CUBE_BOTTOM`; not mounted in Preview's viewport, while the model loads, or when the shell says so, `viewCube`), frame presentation and the queued view-settings handshake. Its children may be a function of the viewport (`{ runtimeRef, hostRef, mountRef, viewerReadyTick, commitScene, syncSceneBounds }`), which is how a renderer mounts its own overlay or pointer pick. A scene that changes IN PLACE (it arrives in pieces, swaps its detail, is rebuilt under one identity) calls `commitScene()` from its own effect: the viewport re-reads what it placed, fits the stage and the depth range and applies the framing rules THEN, because a child's effects run before the viewport's own adoption effect. The one thing a commit never does ahead of the viewport is FRAME under a camera that is about to change: when the same render also changed the lens, the projection or the viewing mode, the stage is adopted at once and the framing follows once the camera has been given those props (a stored camera applied under the old projection and then converted comes out about a sixth smaller). A scene is framed once, on its `restBounds`; a STEP package's is the box its `assembly.json` declares (`bbox`), final from the first publish. A scene that says `complete: false` (a package that declares no box) is framed on what has arrived and once more when it is whole, unless the camera on screen is by then the person's: one they dragged, wheeled, turned by keys or the cube, zoomed to a selection or set through `setCamera` during the current mount, or one a reload restored from the file's view. A rebuild of the open file never re-frames; only Zoom to fit does. A file opened again after the tab left it starts with fresh framing, since leaving it dropped its view. `studio` makes it Preview's viewport (opened at rest, framed afresh from the default direction, keeping no camera, no cube and no drawing tools; `orbit` turns the camera at `orbitSpeed`); `suspended` makes it draw nothing while another view covers it, and draws a frame asked of it meanwhile synchronously, before paint, as it is uncovered (`runtime.setSuspended`). `preserveInteractionPixelRatio` keeps the idle pixel ratio while the camera moves (a scene drawn with hairlines, and Preview's orbit), and `runtimeLifecycle` (`onRelease(runtime, { handoff })` while the WebGL renderer is still alive, `onContextLost()`, `onInitializationError(error)`) is for a renderer that hangs its own objects or in-flight work on the runtime. `syncSceneBounds()` re-fits lighting, shadows and the floor's height to a scene that moved its own bounds, with no React render and no reframe; a sync that finds the bounds (and every setting the fit reads) as they were refits nothing and keeps the shadow maps (`look/stageFollow.js`). What is SIZED stays sized from the rest placement, in Inspect and in Render alike: the grid and stage (`sceneRadiusForBounds` on `restBounds`) and the Render studio's floor plane (`applyPhotographicStudio`'s `groundBounds`), so a pose or a playing routine never rescales or slides the ground under the model; `zoomToBounds(bounds)` frames part of the scene. Read-only test seams, the view on screen's (Preview's while it is open, the editor's again as it closes): `window.__cadCamera()` (the live camera, its depth range included) and `window.__cadStage()` (the ground's radius, the bounds the stage is fitted to, the floor's height, the studio floor's size and centre). |\n| `PreviewView.jsx` | Preview's view: a `ShellViewport` of its own (`preview`, `orbit`, `orbitSpeed`), with its own view-update coordinator over `frame.previewDesiredScene`, drawing the renderer's `studio.scene` and mounting `studio.overlay(viewport, drawn)` inside it. Its canvas, scene, camera and look are its own; only the GPU device is shared with the editor's, which it never reaches. `onPresented` says when its first frame is on screen. |\n| `usePreviewScene.js` | Preview's scene for a family whose core scene has `createSibling()` (GLB, mesh, robot): the editor's scene's sibling, made as Preview opens and disposed as it closes, before the scene it shares geometry with. |\n| `fileView.js` | The file's view `{ version, camera, display, playback, renderer }`, read forgivingly and written exactly: the camera (restored in place of the open-time fit; null fits), the Display settings, the playback settings (`tools/playbar/playbackPreferences.js`), and the renderer's own slices, each `{ signature, value }` and restored only under the signature the renderer declares now (`readFileView(raw, signatures)`); the camera, the display and the playback are always kept. The host keys it `[file path, renderer id]` in the tab store, which keeps it for the file on screen alone. Not in it: the tool in hand, a selection, measurements, ink, Preview, a routine's time — every open starts those afresh. |\n| `liveBinding.ts` | `attachLiveBinding`: the live command surface. Base commands (`readState`, `setCamera`, `resetCamera`, `setDisplaySettings`, `capture`, `thumbnail`) mean the same for every renderer; a renderer ADDS commands by name and DECLINES the known host commands (`HOST_LIVE_COMMANDS`) that make no sense for it with the sentence the caller reads. Binding fails when a renderer does neither. |\n| `promptContext.js` | `createViewPromptContext` (a snapshot and what it depicts) and `promptDeliveryError`. A renderer with a reference vocabulary of its own passes `promptContext` instead, and may then return that vocabulary from `promptReferences`. |\n| `loadReport.js` | `shellLoadReport`: what the shell asks the status kit about one document load, as one pure function, and all it returns is the loading presentation over the viewport. A renderer whose document is a plain download passes `load` and nothing more. One whose document can be EDITED or PREVIEWED while it is open knows more than the shell can: `load.editPending` (queued work of the person's own, with nothing of it on screen), `load.currentPreview` (that work IS what is drawn, so the wait is over even though the write is not) and `load.finding` (the file is not even located yet). An alert is `load.alert`, which the frame draws as the alert card (`ViewerAlertCard`: every alert; one the model survives, a warning beside it included, can be dismissed). |\n| `useViewerShortcuts.js` | Which mounted viewer an Escape or a ⌘C / Ctrl+C belongs to (focus inside it, or the page after a press inside it); the renderer says what Escape means, and a copy is the active tool's (the drawing tools' view with its annotations, else the selection's references, `copySelection`). The order is [the design system's](settings-ui.md#keyboard). |\n| `viewportLayout.js` | The chrome's geometry: its inset from the viewer's edges (`VIEWPORT_INSET_PX`, 8px), the strip's row (`VIEWPORT_TOP_BAR_PX`, 34px), the cube's size and place (bottom-right, 2px from the right, `VIEWPORT_CORNER_INSET_PX`, and `VIEWPORT_CUBE_BOTTOM`, 8px off the bottom so its axes stay inside the view, plus `--cad-viewport-corner-bottom`, which a host whose control floats over that corner sets to lift it clear), the tool column's bottom (`VIEWPORT_STACK_BOTTOM`: an inset above the cube's line). |\n| `ViewportContextMenu.jsx` | The viewport's menu on a secondary TAP (a secondary drag pans; primary and secondary together is the pan chord). The gesture, the anchor, the clamping and the dismissal are the shell's; the ITEMS are the renderer's (`contextMenuItems(press)`), asked at the moment of the press, and `onContextMenuOpenChange(open)` says while the menu is up. A renderer that passes no items has no viewport menu at all — every renderer but STEP. |\n\n```jsx\nconst shell = useRendererShell({\n  view,                 // RendererViewProps, unchanged\n  services,             // { preferences, onPreferenceChange, live?, captureRequest?, acknowledgeCommand? }\n  resource, modelKey, revisionKey,\n  features,             // ViewFeatures: the Display sections this family opts into\n  toolModes,            // createToolModes({ defaultMode, modes }), or omitted: no tools of its own\n  previewable,          // true for a 3D view: it offers Preview; omitted (a 2D view): no Preview, and a request keeps the normal view\n  scene,                // KitScene | null\n  load,                 // { busy, updating?, progress?, alert? }: the renderer's document load\n  animation,            // routine runtime with its own `clock` (and `onRelease`), or null\n  live,                 // { commands?, declined?, state? }\n  // optional: promptReferences, promptContext, escape, sceneScaleMode, preview, tool,\n  //   viewSettings, viewerRef, onCameraSettled, onRuntimeAlert, presentationReport,\n  //   preserveInteractionPixelRatio, runtimeLifecycle,\n  //   rendererState   { signatures, read }: the renderer's slices of the file's view, read when it is written,\n  //                   each restored (by the renderer, `readFileView(view.state, signatures)`) under the same signature\n});\n// Renderer-facing, beside `tools`, `display`, `toolMode`, `selectTool`, `previewing`, `requestRender`:\nshell.requestFrame();            // a frame that keeps the shadow maps: a highlight moves and reshapes no caster\nshell.syncSceneBounds();         // the scene moved its bounds (a pose): the stage follows, no render\nshell.scheduleStateSave();       // a slice changed: write the view soon, and on unmount\nshell.playback; shell.setPlayback(patch); // the file's: Orbit's speed, Autoplay, the chosen Speed and Loop\nshell.orbiting; shell.setOrbiting(on);   // Preview's Orbit, transient: off at every entry and exit\n// The shell adds Display and Preview (a `previewable` view's, in the navbar), Preview's controls (Preview\n// settings, Exit preview), the Animation section for a file with routines, and the sub-toolbar of its own tool (the drawing tools').\nconst tools = [shell.tools.own({ id, label, icon, menu }), shell.tools.annotate].filter(Boolean); // []: no tools, and no strip\n// subToolbar: the renderer's tool in hand's row; sidebar: the file's sections, each a ToolPanel, whatever the tool\nreturn <RendererShell shell={shell} tools={tools} subToolbar={toolRow} sidebar={<>{treeSection}{positionSection}</>}\n  viewportOverlay={viewport => <PointerPick viewport={viewport} scene={scene} enabled={selecting} onPick={pick} onHover={hover} />} />;\n```\n\nA CAD renderer's controls are the shell's chrome: its `tools` on the strip, the tool in\nhand's `subToolbar` (`kit/tools/SubToolbar.jsx`) under it, and its `sidebar`, the file's\nsections down the left edge (`kit/tools/FileSidebar.jsx`), each a `ToolPanel` drawn as a\nsection, whatever the tool: a STEP assembly's Model tree and Position, a robot's Links, an\nSDF's metadata and Position. Sections are their content's height and the bar scrolls as one,\nup to all the room down the left edge or the cap a person set from its bottom-right grip\n(`kit/tools/ResizeGrip.jsx`, Quick Edit's), kept under `sidebar` in the tool stack's layout;\na section folds where there are several, kept by `id`. A tree has no X and is on screen under\nevery tool (out of sight only in Preview, kept mounted with its state); a pick scrolls its row\ninto view there. A renderer may also hand `toolPanels`, the tool stack under the strip and\nsub-toolbar, each panel its content's height, with `fit` saying how it gives way on a short\nviewer: `\"tree\"` first (to 128px or its content), then `\"details\"` together, each in\nproportion to its height (to 96px or its content), never `\"fixed\"`; the column scrolls only\nif the rest still does not fit. Every panel is `TOOL_PANEL_WIDTH` wide (164px, a six-tool\nstrip's); `resizable` makes a panel the person's to size on its own, from the grip in its\nbottom-right corner alone, kept under its `id`. STEP and the robot renderer hand none.\n\nA renderer whose model moves outside React (a robot's pose) keeps that state in\nits own store: `rendererState.read()` is called at the moment the view is\nwritten, so the last write before unmount is saved; `shell.scheduleStateSave()`\nsays it changed; `shell.syncSceneBounds()` carries the moved bounds to the stage.\nNone of them renders a component.\n\nThe workspace half of a renderer's surface is one hook too\n(`renderers/workspace/useWorkspaceDocument.js`): `useWorkspaceDocument({ view, data })`\nreturns the live catalog entry of the prepared file, the prompt `resource`, the\n`services` object the shell takes and the host's commands;\n`workspaceLoadAlert` turns a catalog or loader failure into `load.alert`; and\n`useDeclinedSelectReference` consumes a host's request to select a reference in\na file that has none without changing the view. A renderer is then its\nscene hook, its tool list, its tools' rows and its sidebar sections, if it has any\n(`glb/GlbRenderer.jsx`, `mesh/MeshRenderer.jsx`, `robot/RobotRenderer.jsx`). The DXF renderer uses the workspace module without the\nshell: it has no scene, so it reaches the same catalog entry and commands directly.\n\nThe shell's behaviour has one real-browser test,\n`kit/shell/RendererShell.browser.test.mjs` (deferred files, warm reopen, isolated\nper-pane state, Preview's camera, gated Display sections, settings that never\nreplace the canvas), and the drawing tools have one scenario (`harness/drawScenario.mjs`)\nrun under the shell (`kit/tools/annotate/Annotate.browser.test.mjs`).\n\nThe STEP renderer is on the shell end to end: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`,\nwhose frame, file view and host glue are the shell's. What STEP keeps of its own is\nwhat only a STEP has — its slices of the file's view (`workbench/stepViewSlices.js`,\nread and written by `workbench/useStepView.js`: the tree's expansion, hidden parts and\nisolated assemblies, the pose, the large-file setting, each behind the signature that says\nwhether it still fits the file), its prompt context, and the resource its live state reports while a rebuild is held\nbehind the previous mesh (`live.resource`). The surface composes modules of its own around\nthe shell: Measure's session (`kit/tools/measure/useMeasureSession.js`, the kit's), the pose and routines\n(`workbench/useStepMotion.js`), the context menus as pure descriptors\n(`file-view/stepMenus.js`) and what a selection or a node copies (`file-view/stepCopy.js`).\nThe shell was widened for it rather than forked: it accepts display settings a renderer\nmade earlier than it could hand them back (`viewSettings`), a viewer handle the renderer\nowns (`viewerRef`), externally held tool state (`tool`) and preview state\n(`preview`, from `usePreviewState`: STEP's picking and recognition gates run before the shell's hook),\nand a load report that knows about finding, editing and previewing.\n\nIts safety net is `step/StepRenderer.browser.test.mjs`, which opens a real `.step`\nin a real browser: a two-part component-SURF package with one revolute mate, one\nnamed pose and one routine, committed under `step/__fixtures__/step/` and served by\n`harness/stepScenario.mjs` exactly as the backend serves one. The same two shapes\nare also served as a package that really ARRIVES IN PIECES\n(`stageProgressiveFixture`): twenty-five components held one batch at a time, so\nthe loader publishes at 8, at 24 and at 25 and the MIDDLE publish is an in-place\nchange with nothing else moving. That is the only shape of load that can show what\n`viewport.commitScene()` is for — two components publish once, and a package whose\nsecond publish is its last is re-adopted by the viewport for reasons of its own.\n`kit/tools/annotate/Annotate.browser.test.mjs` covers the drawing scenario under the shell,\nand each renderer's own browser test asserts that the STEP-only Display sections\nare absent from it. The shell surfaces only STEP uses so far (the viewport menu and\nits open report, the camera-settled report, a scene\nthat arrives in place, the pixel ratio kept for hairlines, the runtime lifecycle,\nand what a renderer says about a load that is more than a download) are driven\nthrough `renderers/shell-harness` in `RendererShell.browser.test.mjs`.\n\n## DXF renderer\n\n`createDxfRenderer` (`@text-to-cad/ui/renderers/dxf`, id `dxf`) shows a `.dxf`. The CAD\nrenderer does not match one.\n\nIt is **not on the shell**, and that is the whole design: a DXF is a finished 2D\ndocument, so the pane is a canvas and the drawing is painted on it. No three.js\nviewport, no scene, no Display settings, no Preview. The questions the old\nMaterial/Bends tabs answered were about a sheet-metal part the viewer was inventing\nfrom the file; a drawing is not that. What it has is what a person needs to talk about\na drawing — the kit's chrome, composed around the canvas rather than around\n`ShellViewport` (`DxfRenderer.jsx`): the strip at the top centre, the tool's row under\nit, the file sidebar, Quick Edit at the top-right.\n\n- **Tools** (`dxf/tools.js`): **Select** (in hand; the view's hand too: a drag pans, a\n  tap picks), **Pencil**, **Shapes** and **Measure**, no view controls (Display, Projection and\n  Preview are a 3D view's). Select picks, front to back, a measurement, then ink, then\n  the drawing's ENTITY under the pointer (`drawing2d/entities.js`'s `entityAt`: within\n  6 px, a fingertip's 16; the lines over a hatch before the hatch; nothing on a hidden\n  layer); Shift adds. Its selection is the kit's (`useSceneSelection`,\n  `useSceneReferences`) over a drawing \"scene\" (`createDrawingScene`), lit over the\n  drawing (`drawEntityHighlights`). Its cursors are the 3D views': the arrow, pressed\n  or dragging too, the pointing hand over what a press picks. The drawing tools are the kit's\n  layer (`AnnotationOverlay` with a flat view's `surface`, `dxf/drawingAnnotation.js`):\n  ink lies on the sheet in drawing coordinates (x, y, 0), on no part, painted with the\n  drawing (`drawing2d/marks.js`); its row has no Placement. Measure is the kit's session,\n  overlay and chips (`useMeasureSession`, `useMeasureOverlay`, `measurementChips`) over a\n  plan-view camera (`drawing2d/planView.js`'s `planProjection`); a pick snaps\n  (`snapAt`) to an end, a circle's or arc's centre (`center`, \"Centre\"), else a point on\n  a line, and reads in the unit `$INSUNITS` declares — \"units\", never millimetres, when\n  it declares none. With Measure in hand its row under the strip lists its measurements\n  (`MeasurementsBar`), none while nothing is measured.\n- **References**: an entity by its DXF handle, `drawing.dxf#h2F` (`drawingReferences.js`);\n  everything a block reference draws is its INSERT, a dimension is one entity. Quick\n  Edit's chip names it by type and layer (\"Circle on CUT\", \"Block M5_NUT on HARDWARE\"),\n  its card the handle, type, layer, block, centre and radius.\n- **Layers**, a file-sidebar section, only where the drawing has the structure to read\n  (`layersSectionShown`): four or more layers drawn on, or any block reference. Each\n  layer's pen, name, entity count and eye (hidden: not drawn, picked or snapped to), and\n  under it the block references placed on it, a click picking one.\n- **Quick Edit** carries the picks and the ink (never the measurements: they are the\n  person's); its views are the window on screen as a plan-view camera (`planCamera`: looking\n  straight down, `up` +Y, `orthographicHalfHeight` the window's half height), which `cadgen dxf\n  snapshot --job` frames again, drawing the job's annotation over the drawing.\n\n- **The picture comes from the BACKEND.** `client.drawing(file)` is\n  `GET /__cad/drawing` (`apps/web/docs/backend.md`), asked again while the server\n  renders a drawing it has not drawn before: ezdxf flattens the modelspace on the\n  server — text placed, dimensions exploded, hatches filled, blocks placed — and the\n  client receives five primitive shapes and `text` in DXF coordinates, y up. **This\n  renderer never parses DXF.** The payload is cached server-side by the document's\n  content hash, so reopening a file costs a round trip and nothing else.\n- **Drawing is core's** (`@text-to-cad/core/lib/drawing2d`), so the headless snapshot\n  bundle paints the same picture from the same payload: `fitTransform` / `zoomTransform`\n  / `panTransform` (one uniform scale and a translation; the y flip lives in the\n  transform, not in the geometry), `prepareDrawing(payload)` (paths built ONCE, per\n  colour for strokes and per primitive for fills; text kept as strings) and\n  `drawDrawing(ctx, drawable, …)` (text set with `fillText`, stretched to the width the\n  server measured, so the page's face keeps the drawing's layout).\n  That bundle IS `cadgen dxf snapshot`: cadgen resolves a `.dxf` to the same\n  `cadgen.drawing_payload` this route answers with and the page fits and paints it\n  (`common/headlessDrawingRender.js`), so a CLI render cannot show what this pane\n  cannot. The CLI's option surface was cut to match — no display, no render mode,\n  `--appearance light|dark` and a size; a job may add a plan-view `camera` and an\n  `annotation` on the sheet, what a Quick Edit's views carry.\n- **Hairlines, always.** Strokes are 1.25 CSS px at every zoom, as AutoCAD draws with\n  LWDISPLAY off: paths are in MODEL space and the context carries the view, so one\n  `lineWidth = 1.25 / scale` per frame rebuilds no geometry. Model-space lineweights\n  are not displayed at all.\n- **`color: null` is the default pen** (ACI 7, \"whatever contrasts with the\n  background\"), resolved at DRAW time against the app's `--foreground` on its\n  `--background` — the same pair the 3D viewers' chrome uses. One payload therefore\n  serves both themes: flipping `.dark` repaints, it does not refetch. The tokens are\n  read off the pane, and a mutation on `<html>` schedules a frame. When they cannot be\n  read — and in the snapshot bundle, which has no stylesheet at all — the pair comes\n  from `@text-to-cad/core/lib/appTheme.js`, the one place both colours are written down.\n- **Fills are even-odd, and each one is filled on its own.** A `filled-paths`\n  primitive's inner rings are its holes; merging two overlapping regions of one colour\n  into a single path would turn their overlap into a hole as well. Fills go down\n  before strokes, which is the one ordering that never hides an edge.\n- **Interaction** (`dxf/useDrawingView.js`): fit on open and on resize until the\n  person moves the view, drag to pan (a primary press, one finger, once it has travelled\n  more than a tap's 4 px; a press that lets go before is the tool's tap), wheel or pinch to\n  zoom about the pointer, double-click to fit again. The view lives in a REF and the\n  canvas repaints through one `requestAnimationFrame` when something changed; a pan\n  never re-renders the component tree. A resize paints at once, inside the pane's\n  ResizeObserver (after layout, before paint): resizing the canvas wipes it, and a frame\n  asked for there would leave the pane empty for one frame\n  (`dxf/DxfResize.browser.test.mjs`). The backing store is DPR-aware\n  (`kit/viewport/pixelRatio.js`). What the tools draw (highlights, ink) is painted in\n  the same frame, after the drawing, so a capture holds it.\n- **Navbar**: nothing of the drawing's — no zoom buttons, because zooming a drawing is\n  the pointer's: wheel or pinch about it, drag to pan, double-click to fit. The host's\n  `captureRequest` is the canvas as a PNG, background included, delivered through\n  `host.promptContext`.\n- **Empty and failed drawings.** `bounds: null` (nothing in the modelspace) is a quiet\n  sentence over the empty pane, not an error. A non-200 becomes the ordinary actionable\n  alert card (`ViewerAlertCard`) carrying the SERVER's sentence (`failureAlert`,\n  `kind: \"http\"`); a payload from\n  a cadgen that disagrees about `schemaVersion` gets its own alert whose recovery is to\n  update cadgen and the app together.\n- **Host commands**: `resetCamera` fits the drawing again, `capture` hands over the\n  PNG, `clearSelection` lets go of the picks, and `readState` reports `camera: null`,\n  an empty `display`, the selection as references \n\n<!-- [TRUNCATED AT 50KB] -->","isInternal":false,"tokens":12142,"sizeBytes":50094},{"name":"feature-detection.md","path":"packages/ui/docs/feature-detection.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/feature-detection.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Client-side STEP feature detection\n\nFeature detection helps people navigate and select geometry in the shared Model\ntree. It belongs to the STEP renderer in `@text-to-cad/ui`; web and desktop run the\nsame code. It reads saved geometry through the injected CAD resource service.\nIt never reads model source, writes artifacts or executes a CAD kernel.\n\nKeep recognition independent of cadgen compilation, Python inspection and the\nreference grammar. A recognized feature is a group of existing canonical faces\nand edges, not a new kind of reference. Selecting or copying it uses the normal\noccurrence-scoped reference and prompt-delivery flow. Feature labels and inferred\noperations are not stable identities across document edits or recovered source\nhistory. Recognition is optional: structural rows, rendering and part selection\nmust work without it.\n\n## Demand and cancellation\n\n1. Load the lightweight assembly descriptor to display the hierarchy.\n2. The Model tree requests recognition for expanded, visible parts. Collapsed\n   parts need no recognition or exact-surface request just to show their rows.\n3. Resolve each requested occurrence to its component. Repeated instances share\n   component analysis while retaining distinct selection IDs and placement.\n4. Reuse completed recognition metadata when its exact geometry identity matches.\n   Otherwise obtain the component's SURF data through the resource provider and\n   analyze it in a disposable worker.\n5. Publish the result, cache successful metadata, then process the next requested\n   component. Each mounted inspector owns at most one active recognition worker.\n\nChanging expansion updates the queue without restarting an active component that\nis still requested. Switching between occurrences of that same component also\nkeeps its work. Removing the last requested occurrence cancels both pending\nsurface loading and worker work; queued components that are no longer requested\nare skipped. Taking the model tree off screen (choosing another tool, or entering\npreview), changing geometry or resource scope, and\nunmounting cancel the entire inspector's pending work. Cancelled jobs cannot\npublish late results or populate the completed cache.\n\nThis is deliberately a sequential queue per inspector, not a global worker pool.\nCompleted results are reusable between mounts; pending jobs are not shared\nbetween separate inspectors. Retry clears failed results and queues those\ncomponents again without cancelling other components that are still needed.\n\nLoading is lazy **per component**: expanding a part analyzes that component as a\nwhole, not only the feature rows subsequently opened. Recognition neither expands\nother parts nor blocks the initial structural tree. The stable top row counts\npresented top-level features; it does not display global recognition progress.\nSelection, isolation and reveal rules remain in the\n[Model tree contract](cad-renderer.md#step-panels).\n\n## Cache identity and lifetime\n\nThe completed cache key combines:\n\n- The resource provider's workspace/generation scope.\n- `MODELING_RECOGNITION_VERSION`.\n- Exact surface input and object identities, or a provider-scoped immutable SURF\n  URL for older static packages.\n\nDo not key recognition by filename, display label or assembly occurrence. On a\nwarm reopen, accepted component identities can avoid surface requests altogether,\nbut only after workspace, provider generation, entry revision and runtime descriptor\nmatch. Changed geometry or scope must never reuse the previous binding.\n\nThe least-recently-used cache holds at most **512 components / 8 MiB** of accounted\nserialized metadata. Each read returns a private copy. It retains no SURF buffers,\nworkers, scene objects, pending promises or failed results. The active inspector\nalso retains its own results, including errors for explicit retry; disposing it\nreleases that local state. Large or evicted results may require recomputation.\n\nThis cache is **in memory**, separate from cadgen's persistent surface/mesh store\nand host preference storage. Refreshing the page or restarting the app loses it.\nThere is no recognition sidecar, disk database, compile step or backend endpoint.\n\nAdvance `MODELING_RECOGNITION_VERSION` in\n[modelingRecognitionCache.js](../src/renderers/step/workbench/modelingRecognitionCache.js)\nwhen inference rules, tolerances or serialized results change, including returned\nlabels, measurements and face/edge grouping. This is an internal algorithm\nrevision, unrelated to the repository's release version. Presentation-only\nchanges outside cached results do not require a bump. Geometry and resource\nrevisions remain independent parts of the key; the algorithm version replaces\nneither. If configurable recognition options are introduced, their semantics\nmust also participate in the key.\n\n## What the recognizer infers\n\nThe current algorithm first tries a complete extrusion, revolution or supported\nsolid reconstruction. Otherwise it presents an imported body with local feature\ncandidates and remaining geometry. That precedence is a presentation heuristic:\nthe same circular cut can appear as a cut extrusion or a bore in different\nbodies. Changing these classifications is separate from cache/lifetime work.\n\n- **Extrusions:** paired analytic cap contours, straight walls, side area and\n  solid volume support a possible base/cut sequence. Profiles select real STEP\n  boundary edges.\n- **Revolutions:** full coaxial cylinder/cone solids can yield a closed axial\n  profile and 360-degree revolution checked against solid volume.\n- **Pockets and bosses:** local planar caps, translated rims and straight walls\n  identify supported prismatic regions. Current local tests cover convex analytic\n  profiles; they do not cover every pocket or boss.\n- **Bores:** adjacent cylindrical patches are joined across export seams.\n  Annular shoulders and full conical transitions can connect stages into blind,\n  countersunk or counterbore candidates. Boundary samples and enclosed face\n  bounds reject evident intrusions; they do not prove an empty volume.\n- **Blends and other supported solids:** connected constant-radius\n  cylindrical/toroidal blends, rounded boxes and supported ruled lofts have\n  analytic recognition paths. Single curved corners are not automatically\n  separate fillet operations. Shells and patterns are not invented.\n\nUnclaimed faces remain available as other geometry. Candidate ordering is not a\nmodeling timeline, and a missing candidate does not establish that a feature is\nabsent. Measurements describe supported geometry; recognition is not engineering\nvalidation. Independent kernel reconstruction checks belong to development tests,\nnot the runtime viewer or Python inspection API. Complete reconstruction claims\nrequire both directional Boolean differences to agree; finding a local candidate\nor matching its tool volume alone does not establish a rebuildable whole part.\n\n## Limits and failures\n\nThe worker ticket permits at most 16 MiB of SURF data. Analysis rejects inputs\nover 6,000 faces or 20,000 edges; the worker/ticket stage has a 10-second timeout.\nThese are safety limits, not promised execution times. Surface derivation before\nworker startup has its own resource-service lifecycle.\n\nUnavailable data, unsupported-size inputs, worker errors and timeouts produce a\ncomponent error and explicit retry. They do not become reusable cached results.\nOther requested components can still be processed. Existing ordinary geometry\nand part selection remain available. Keep workers off the UI thread, preserve\nthese bounds, and profile large parts before adding persistent caching or more\nconcurrency.\n\n## Implementation and verification\n\n- [useModelingRecognition](../src/renderers/step/workbench/useModelingRecognition.js):\n  descriptor demand, per-inspector queue, resource requests and cancellation.\n- [modelingTree.worker](../src/renderers/step/workbench/modelingTree.worker.js):\n  resource-ticket loading, SURF parsing and inference.\n- [modelingTree](../src/renderers/step/workbench/modelingTree.js) and adjacent\n  `modeling*` helpers: pure inference and canonical reference mapping.\n- [modelingPresentation](../src/renderers/step/workbench/modelingPresentation.js) and\n  [ModelingTree](../src/renderers/step/components/workbench/ModelingTree.jsx):\n  occurrence binding, presentation, expansion and selection.\n\nKeep renderer-private pure helpers here; they do not need to move to core merely\nbecause they do not import React. Hosts implement resource effects through the\n[viewer host contract](viewer-host.md), never a second recognizer.\n\nRun `npm --prefix packages/ui test` from the repo root for helper and React\ncoverage. Recognition tests cover real STEP-derived fixtures, version/geometry\ninvalidation, cache bounds and warm reopening, expansion during pending work,\nrepeated instances, retry, disposal and rejected late results. Fixture provenance\nlives in the [fixture guide](../src/renderers/step/workbench/__tests__/fixtures/README.md).\nAdd small test-owned regression fixtures for changed inference rules. Rebuild\npackage exports and the bundled viewer before testing either app.\n","isInternal":false,"tokens":1778,"sizeBytes":9148},{"name":"file-viewer.md","path":"packages/ui/docs/file-viewer.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/file-viewer.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# FileViewer\n\n`@text-to-cad/ui/file-viewer` exports the complete file view: one file by its\nabsolute path, under the navbar, with the explorer its name opens, renderer loading,\nand the loading and error pages.\nIt imports no concrete renderer. Applications compose registrations from the\nseparate `@text-to-cad/ui/renderers/*` entry points and their own\n`defineFileRenderer` definitions ([renderers](renderers.md)). A CAD host composes\n`CadViewer` (`@text-to-cad/ui/cad-viewer`), which is this component with the five\nCAD renderers, the home and the standard error pages ([viewer host](viewer-host.md)).\n\n```tsx\n<FileViewer\n  file={selectedPath}   // absolute; null is the host's home\n  host={viewerHost}\n  renderers={registeredRenderers}\n  state={viewState}\n  onStateChange={persistViewState}\n/>\n```\n\nSee [ViewerHost](viewer-host.md) for required host services and prompt delivery.\nCreate sources and registration arrays at their owning workspace or tab\nlifetime. Changing their object identity cancels outstanding document work.\n`source.id` is a stable identity, independent of a server's temporary port. Every\npath is absolute and `/`-separated (`/a/b.step`, `C:/a/b.step`); containment and\nauthorization remain in the host service. The host controls navigation\n(`navigation.openFile(path)` where the view can show another file, and `home()` where it\nhas a home; a view that shows one file alone has neither, and no explorer) and keeps\n`FileViewerState` in its tab store (`@text-to-cad/ui/tab-store`; `useTabViewerState`\nderives it): `renderers`, under encoded `[file path, renderer ID]` keys, each the\nfile's view.\n\nA source reads files and never writes them. `stat(path)` answers a file's metadata;\nwhere its files can be browsed, `list(directory)` answers a folder's entries and\n`search(directory, query)` the files under a folder whose path below it holds the\nquery, saying when it stopped early. What the file menu offers is the host's\nseparate `fileActions` capabilities. Sources receive an `AbortSignal` for each read;\nnoncancellable reads still check the signal before returning. A renderer that\nacquires a resource (a URL lease, a worker) returns its release as the prepared\ndocument's `dispose`; FileViewer calls it on navigation, reload, cancellation and\nunmount.\n\nUse `defineFileRenderer<T>` to keep the prepared payload paired with its lazy\ncomponent. A definition supplies `id`, numeric `priority`, `matches`, `prepare`,\nand `load`, and optionally `fallback`. Larger priorities win.\nDuplicate IDs and equal-priority matches\nproduce explicit errors. At most one registration may declare `fallback: true`;\nit runs only when no ordinary registration matches. Registration construction\nand matching do not load the component.\n\n`prepare` returns `{ data, dispose? }`. FileViewer opens a file's document when the\nfile is shown, and again only on `reload`: a renderer follows its file's changes\nitself (every CAD renderer loads the next revision behind what is on screen). A CAD\nfile's controls are its own, over the viewport: its tool strip, the tool in hand's\nsub-toolbar, and its file sidebar\n([the design system](settings-ui.md#the-file-sidebar-and-the-tool-stack)); the view's\ncontrols (Display, Preview) are the navbar's, last at its right end.\n\nBelow 600px of its own width FileViewer is mobile (`useViewerMobile`). The width is a\nbreakpoint boolean, never a pixel value, so a resize within one layout does not\nre-render the renderer.\n\nA renderer is handed, besides its document: `onNavigationActionsChange`, for its\nnav-row actions (`FileNavigationAction`, with an optional shorter `hint`), hidden\nwhile it reports `onReady(false)`; `appearance`; and `onOpenFile(path)`, which shows another file, by\nits absolute path, in this view (a robot's mesh), absent where the view shows its file alone. Its loading and update status are its\nown to show: a CAD renderer shows them in its viewport. Host-specific pages are supplied through `presentation`,\nwithout duplicating the view's placement: `home` is a host's own page for a view with\nno file (every CAD host's model library), under the navbar; `loading`; and\n`error({ message, missing })` for a file that will not open, `missing` when it does\nnot exist. Per-file renderer state is also accepted during a departing renderer's\ncleanup, while it still belongs to the same source.\n\n## The navbar and the explorer\n\nThe navbar is drawn over every file from the moment one is asked for, and over the\nhost's home — only its logo and menu, without Back to files, Back and Forward where the\nhost keeps the view's history, and the host's update and Full size, where they stand over\na file — never for a `compact` view (a\npicture: the home's thumbnails); the CAD viewer's Preview keeps it. Over a file, left to right:\n\n- the text-to-cad \"C\" logo (named Menu, with no chevron), which opens the app menu\n  (`navigation/AppMenu.jsx`): Back to files where the host has a home\n  (`navigation.home`), the person's settings (`appSettings`), Send feedback, GitHub,\n  Discord and, in gray, the version and who made it. FileViewer builds it from\n  `host.links`, `appSettings` and `environment.platform`. A host with no home has the\n  logo and the menu too, without Back to files;\n- Back and Forward, where the host keeps the view's own history (`history`, a\n  `ViewerHistory`: the CAD app's views; a browser's page has its own and hands none): two\n  arrows, each there always and disabled where it has nowhere to go;\n- the open file's name: a button that opens the explorer where the source can both list\n  and search, and plain text otherwise;\n- the ⋯ file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host's `fileActions` can do it, and no ⋯ where it can do\n  none.\n\nThe logo, Back and Forward, the name (where it opens the explorer) and the ⋯ are one kind of control: transparent\nat rest, the same accent background while the pointer is on it and while its menu is open, a\npointer cursor, and no tooltip.\n\nAt the right, only what a host or a file adds: the host's update button, a renderer's\nactions and the host's Full size, where the host shows the view small. A renderer's\nown view controls (a 3D view's Display and Preview, or Preview's settings and Exit preview)\ngo last, in the box FileViewer hands it (`navbarSlot`, `data-view-controls`).\n\nThe explorer (`navigation/FolderExplorer.jsx`) is a popover under the file's name, a\nfixed 18rem wide and never resized. It floats over the view, so opening it resizes\nnothing, and it is built anew on each opening, so it starts in the open file's\nfolder, its filter taking the keyboard (on a phone the explorer holds focus itself, so no\nkeyboard comes up). It lists one folder at a time through `source.list`: the folder's\nsubfolders, then its CAD files, the open file highlighted, in compact rows (a folder's glyph\nits disclosure chevron). A press on a subfolder opens it inline, under itself and a little\nfurther in, reading it through `source.list` the first time; a double-click opens it in the\nexplorer's place. A breadcrumb shows as many of\nthe last three folders of the one it is in as fit whole, measured before it is painted:\na crumb is never cut, the farthest giving way first to a \"…\" button whose menu lists the\nfolders above the crumbs to jump to, and only the folder it is in, alone beside the \"…\",\nis cut to the row. A crumb, as a double-click does, opens a folder in place, and there is\nno up arrow. \"Filter files...\" searches every file nested under the folder it is\nin through `source.search`: the client waits 150 ms after the last keystroke and\naborts the search before it, and each match shows its path below the folder, the\nsubfolders muted before the name. An answer that is `truncated` adds \"Stopped early.\nKeep typing to narrow the search.\" The viewer server bounds both reads\n(`GET /__cad/folder`, `/__cad/search`): hidden and build folders are left out of each,\nand a search stops at 200 results, about 1.5 s or depth 24, walking a folder it reaches\ntwice once. A pick opens the file through `navigation.openFile` and closes the popover.\nUp and Down move between the filter and the rows; Escape closes it like any popover.\n\nThe browser harness beside FileViewer exercises injected renderers, source and file\nchanges, navigation actions, the explorer and the file menu, the mobile breakpoint,\nand resource disposal. Run it with the package test command after building the shared\npackages. Chromium must be available for the browser tests.\n","isInternal":false,"tokens":2019,"sizeBytes":8529},{"name":"lod.md","path":"packages/ui/docs/lod.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/lod.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Level of detail, memory, and live updates\n\nWhat the viewport does in the background between \"a file was selected\" and\n\"the picture has settled\": which tessellation it starts from, how it refines,\nwhat it refuses under memory pressure, and how it follows a model that is\nbeing rebuilt while you watch.\n\nAll of it is the STEP package path of the STEP renderer. A triangle mesh (STL, 3MF) and a\nGLB have no levels: their renderers load the file whole, parse it once per revision (an\nSTL in a worker) and keep the decode cached, so reopening one fetches and parses nothing\n(see [Mesh renderer](cad-renderer.md#mesh-renderer)).\n\nThe observable promises are in [the web app README](../../../apps/web/README.md#the-laws-that-bind-the-app);\nthis file is the mechanism behind them. None of it may change **exact**\ngeometry, measurements or explicit mesh-export tolerances — detail is a\ndisplay concern only.\n\n| § | Covers |\n|---|---|\n| [1](#1-where-a-model-starts) | Coarse-first admission, cached standard meshes |\n| [2](#2-what-refinement-samples) | Camera sampling, offscreen components, hysteresis |\n| [3](#3-admission-and-memory-pressure) | Budgets, reservations, coarsening caps |\n| [4](#4-the-replacement-batch) | CID caps, the first-ready deadline, adoption receipts |\n| [5](#5-cancellation-failure-and-cleanup) | Parked targets, restoration, ownership |\n| [6](#6-following-an-active-build) | The edit feed, what the viewport shows, the status heartbeat |\n\n## 1. Where a model starts\n\nAssemblies with at least 64 unique components can start at a coarser display\ntessellation when standard meshes are not cached. Cached standard meshes are\npreferred immediately, subject to their probed decode size and admission. The\ntiers are probed a chunk of components at a time and the cached bodies read a\nbatch at a time (`createInitialDisplayPlans`, `packageBatchReads.js`), the first\nof each the size of the first publish, so the first geometry waits on no more\nthan it draws.\nSmaller assemblies start at the standard level, except that an individually\noversized component may start coarse. A component above the concurrent decode\ncap runs alone only when the shared Viewer memory envelope can reserve its\ncomplete estimate.\n\nCoarse geometry is a temporary preview: visible components automatically reach\nat least the standard level, preserving its angular smoothness even when\nprojected chord error alone would permit a coarser mesh. Close inspection can\nrequest finer detail, and so does preview, which the STEP renderer draws one\nscene-quality tier up (`kit/viewport/previewViewScene.js`) without changing the Display\nsetting: while Preview is open the loader samples Preview's camera and asks at its\ntier, both scenes reconcile the same published mesh, and only the editor's adopts it. The chrome does not report detail levels: the STEP renderer\nrecords first geometry and standard detail for benchmark harnesses only\n(`window.__cadViewerQuality`, `useViewportQualityStatus`), and background file\nwriting stays quiet. That record lives in the STEP surface, so it takes a scheduler\nsnapshot only when the status it derives would change (`lodSnapshotStatusKey`): a\npreview orbit resamples the camera on every frame, and a snapshot per frame would\nre-render the whole surface per frame — while a model streams in, redoing the\npartial model's derived state each time.\n\n## 2. What refinement samples\n\nRefinement uses the camera and the disposable memory budget.\n\nStatic assemblies sample full transformed occurrence bounds against the camera\nfrustum, refining a component when at least one occurrence is on screen.\nOffscreen components stay displayed: ordinary camera sampling retains their\nexisting detail, and memory pressure can coarsen them before visible\ncomponents. Unknown or not-yet-adopted bounds remain eligible.\n\nA stationary camera requests the final level implied by the existing hysteresis\nthresholds directly. If admission refuses that level, strictly intermediate\nlevels can supply measured replacement sizes for another try.\n\n**Resampling a motionless viewport is not work.** A sample carrying the same\ncamera, viewport and per-component distances, visibility and selection neither\nrestarts the settle debounce nor republishes status, so a viewport nobody is\ntouching reaches settled quality and stays there. An idle scheduler reports\nmemory-limited targets separately from settled quality. Camera and distance\ncomparisons ignore floating-point noise at a 1e-10 relative tolerance, measured\nagainst the last meaningful sample so accumulated movement still triggers work.\nVisibility, selection, quality changes and explicit retries remain meaningful.\n\nScenes with joints, embedded animation, drawing poses or an active/collapsing\nexploded view keep conservative eligibility, including paused or disabled pose\ncapabilities. Authored visibility and material flags are not LOD filters.\n\n## 3. Admission and memory pressure\n\nFailed loads stay parked; denied admission retries only after the displayed\nlevel or camera intent changes. Pressure-driven coarsening caps subsequent\nrefinement until the camera or viewport changes, preventing upgrade/downgrade\nloops. Mesh-bound and clip-plane updates do not reset that cap. Pressure\ncoarsening remains singleton.\n\nAdmission can reclaim idle tessellation workers and retry while preserving\nactive consumers. Its ledger samples each live worker's own retained estimate\nbefore admission, so a large component does not inflate every worker's charge.\nRefinement reserves both replacement arrays and worker scratch space, and\nincludes the coarse tier's relaxed angular tolerance in its estimate.\n\nDisplay arrays shared with asset caches have one CPU charge for the entire\nbacking allocation, including unused sections of packed buffers. GPU charges\nuse uploaded view sizes; CPU-only edge inputs and picking allocations are\naccounted for separately.\n\nA component that cannot fit even at the coarse level reports a limitation and\npreserves the current view. **Estimates and sampled resource totals are a soft\nbudget, not a hard browser RSS limit.**\n\n## 4. The replacement batch\n\nThe scheduler holds at most four distinct replacement CIDs across loading,\nready payloads and actual scene adoption. Its render and late-selector\npreparation share one loader lane, and only one atomic mesh/reference\npublication awaits adoption.\n\nThe Viewer uses a 128 ms first-ready collection deadline so serialized cached\nreads can fill the four-component batch. It may publish a ready subset beside\none unfinished carryover; it does not guarantee selector, worker or scene\nreadiness. No fifth replacement starts. Admitted refinements keep filling the\nbatch while exact sibling reservations allow it, even after the coarse pressure\nthreshold is crossed; a denied reservation flushes the ready subset.\n\nSeparate user-driven topology requests keep their existing worker admission and\ncache/picking accounting; they are not included in the scheduler's occupied-CID\ncount. Topology-only interactions also release idle workers after their sibling\nrequests drain.\n\nActual payload backing allocations are reconciled before another sibling is\nadmitted. Temporary sibling-capacity denials flush and retry after ownership\nchanges; they do not permanently park a target. Displayed levels and measured\ncurrent sizes remain unchanged until the complete exact batch adopts.\n\n**Adoption receipts.** Replacement admission stays held until the viewer adopts\neach current component payload at every occurrence and accounts for its scene\nownership. This acknowledgment schedules rendering; it is not a GPU\nupload-completion fence, and modeled upload ownership remains separate. A\nsuperseding progressive publication can satisfy it only with the same context,\nrevision, occurrence set and exact payload. Display geometry and demanded\nselectors publish as one matching state pair, with commit receipts fencing\nabandoned or replayed React updates.\n\nEvery publication lands in the ONE STEP scene the viewport adopted\n(`renderers/step/scene/stepScene.js`): while the structural build key holds for\nthe same model, the live build reconciles its records in place, and the viewport\nis told the scene changed (`viewport.commitScene()`) rather than handed a new\nscene — so a progressive open or a detail swap never re-dresses every material or\nre-adopts anything. The camera frames the model once, on its first publish, on\nthe box `assembly.json` declares for the whole of it (`bbox`, carried as the\ncomposition's `declaredBounds`): the scene's `restBounds` is that box from the\nstart, so the framing, the orbit pivot, the zoom ruler, Zoom to fit, preview's\nturntable and the ground's size are final before most components have arrived,\nand later publishes move none of them. A descriptor without a box leaves the\nscene `complete: false` until the last component is in: it is framed on what\narrived first and once more when it is whole, unless the person has taken the\ncamera by then. The camera sample that drives all of this is taken when the\nviewport says the camera settled (`onCameraSettled`: a move, a preview orbit, or\na resize, which can expose a part without moving the camera) and when the\nselection changes.\n\nA static component publication can reuse the main adoption's completed reset\nonly in that same React render. Later visual or clipping changes still run\nnormally, as do transitions out of modules, animation, drawings or poses.\n\nProgressive display and later detail swaps share unchanged occurrence rows and\ntree metadata; changing tessellation alone does not rebuild every tree leaf.\nPlacement, appearance and changed bounds still update their records. Selection\npruning preserves unchanged selected, referenced and hidden ID arrays,\npreventing detail publications from retaining historical workspace render\ncontexts through unnecessary selection updates.\n\nDisplay raycast accelerators are requested only when a picking ray reaches\ncomponent bounds, then queued during idle time for one worker at a time.\nAdmission covers private input copies, worker scratch and the returned tree;\ndisplayed arrays stay attached and unchanged. Releasing the last geometry owner\ncancels its build, and stale results cannot attach to replacement geometry. The\nfirst pick remains exact and may cost more on a dense component; merely loading\nor refining an assembly does not build an accelerator for every component.\nInputs with a separate merged face-selection proxy still build that proxy's\naccelerator on the main thread during idle time. Canonical STEP selectors use\nthe display meshes and do not enter that separate path.\n\nDiagnostic snapshots identify scheduler-only ownership, batch sizes and seal\nreasons. The internal size-one control uses the same admission/publication path\nas groups of four.\n\n## 5. Cancellation, failure and cleanup\n\nCancellation requests cleanup: switch, abort or unmount retains an outstanding\nreservation until actual replacement, restoration or complete disposal proves\nthat the renderer has released its previous owner. Pending component maps\nremain separate from adopted maps.\n\nA failed scene update clears its partial records before rebuilding the last\nadopted mesh/selector pair; it never reconciles against already-disposed\nrecords. Restoration preserves unrelated progressive components and completed\nselector loads. A second construction failure stops detail work and reports an\nerror. Cleanup failure keeps ownership charged until a real cleanup retry\nsucceeds.\n\nA cancelled batch that actually adopted remains a displayed payload owner even\nthough its scheduler levels are not promoted, so cancellation does not evict\nits exact cache entries.\n\n## 6. Following an active build\n\nSTEP entries always follow active edits, showing the root preview before its\nSTEP save. Run the model normally; existing decorators need no new imports, and\nthe daemon must be running for live updates.\n\n**The feed.** Updates arrive through a held request that wakes when this\noutput's build ledger changes. Unrelated jobs do not wake the tab. The server\nadmits 32 waiters independently of kernel workers; excess tabs retry every\n500 ms. A server that answers at once instead (a host relaying requests through a\nfew slots all its views share, where a held request would take one) is paced by the feed: an answer\nwith nothing new waits out the rest of a second, and news is asked after again\nwithin a tenth of one. An idle heartbeat revalidates saved bytes and missing geometry; closing\nor switching the tab cancels the request. Older status responses cannot\noverwrite newer cached progress, and saved revisions are verified from one\ncoherent file snapshot. Restarting the daemon expires the ephemeral session,\nand rerunning the model reconnects it.\n\n**What stays on screen.** The prior model stays visible while the next request\nbuilds; failed updates remain visible while an idle disconnected feed retries\nquietly. A complete model of the same file — a part's or an assembly's — also\nremains visible while its rewritten file is rebuilt and while the replacement\nmeshes load (`replacingSameFileMesh`, `awaitingSameFileRevision`), reported as an\nupdate (\"Updating model…\"), never as the loading screen — also when edits come\nfaster than revisions load, and one revision's load is cancelled for the next\n(`meshStateAfterCancelledLoad`). Complete displayed component arrays remain available while a\nreplacement stages or fails. Reuse requires the same runtime surface input,\nconcrete surface object and tessellation; placements and appearance come from\nthe new tree. Selection, measurements and reference copying wait for matching\nnew geometry. A failed replacement preserves the view and reports its error;\nonly that file/hash stops retrying automatically. A first load that fails part\nway keeps the parts it drew under its error, and refinement goes on over them: a\ndetail swap changes geometry only, never whether the load finished or failed\n(`detailSwapMeshState`). STEP pose and animation\nmetadata use their normal loading path, without a promise to retain the\nprevious pose. Snapshot source isolation is unchanged.\n\n**Overlapping loads.** Geometry and reference loads own their cancellation\nindependently; a superseded request cannot cancel its replacement. Topology\nrequests for one file revision union rather than supersede: a batch in flight\nfinishes, and one no longer wanted is simply not published.\n\n**After a save.** The view shows the saved file. When a build writes it, the new\nrevision replaces the one on screen in place, keeping every component whose identity\nit shares; while the build runs the model on screen says it is updating, and a failed\nbuild leaves it on screen under its alert.\n\n**What the viewport shows.** The navbar carries no status. Opening and\nupdating are the viewport's loading overlay (`ViewerLoadingOverlay`). A failure is\na card over the viewport (`kit/status/ViewerAlertCard.jsx`) with its explanation,\nits next step and the full diagnostic under Details: an error always shows, and a\nfailed update the model survives (`blocking: false`, the previous version still on\nscreen) has a Dismiss button and stays dismissed until the alert changes, or\nclears and is raised again. A warning is a card too, one that can be dismissed\nwhile the model is on screen: the card is the one place a problem is said. Once a\nusable current view is displayed, saving, successful completion, idle edit-feed\nstate and routine refinement stay quiet. A sidecar this build cannot read raises\nno alert: the model renders with no kinematics, no materials and no routine, and\nthe migration is announced where it can be acted on — the build and the cad\nskill. Existing usable views remain visible during updates and failures.\n\n**Opening.** Opening shows one step line — **Finding file**, **Reading\nmodel**, **Loading geometry**, or **Preparing view** — with no headline above\nit; the loading mark itself says a model is opening. Counts measure\ncompleted geometry items in the current stage, not assembly occurrences or an\noverall ETA; uncounted stages are indeterminate. Render initialization uses the\nsame indicator against the destination backdrop until its first usable frame.\nLong waits show elapsed time; interrupted progress explains that the viewer is\nwaiting for a response before offering recovery. Selection and edge preparation\nreport beside their controls, not as whole-model loading.\n","isInternal":false,"tokens":3346,"sizeBytes":16436},{"name":"render-mode.md","path":"packages/ui/docs/render-mode.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/render-mode.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Display modes\n\nThe Viewer and snapshots share one strict sparse contract. Core's `common/viewSettings.js`\nvalidates it and resolves the mode, or Preview's Render, into renderer groups.\n`common/sceneSettings.js` translates those groups into the scene recipe. The binding\ncontrols are in [settings-ui.md](settings-ui.md#display-settings-and-section-primitives).\n\nA file's display is a mode and the Grid switch. A mode is a look at its own defaults, with\nnothing to set. Render is no display mode: it is the photographic look Preview and a\nsnapshot's `--preview` ask for on their own, over the same display, with Preview's own\nsettings (the viewer's alone, kept beside the display).\n\n| Mode | Surface / edge recipe |\n| --- | --- |\n| Solid (default) | Shaded, the model's visible CAD edges |\n| Shaded | Shaded, no edges |\n| Parts | Solid, each part its own palette colour |\n| X-ray | 22% opacity, all edges |\n| Hidden line | Depth-only surfaces, visible edges (light on a dark backdrop) |\n| Wireframe | No fill, all edges (light on a dark backdrop) |\n| Render (Preview's, asked for on its own) | Shaded, no edges, no grid, photographic studio |\n\nA file without CAD edges (STL, 3MF, GLB, a robot) offers Solid, drawn without edges, and\nParts where it has several parts, objects or links; Shaded would be Solid again there. The\ngrid is off by default and the same measuring grid in every mode. Appearance, camera, Clip\nand Explode are shared. Camera defaults to orthographic with a 50mm lens; Render resolves to\nperspective without overwriting that shared camera choice.\n\n## Sparse public contract\n\n```json\n{ \"mode\": \"parts\", \"grid\": true, \"appearance\": \"light\", \"camera\": { \"projection\": \"orthographic\" } }\n```\n\nOmitted mode is Solid, and `mode` is one of the six; `grid` is a boolean, off unless said.\nThe display also takes `clip` and `exploded`. Anything else is refused: there are no\nper-mode settings, style groups or axes. Preview's own settings (`parts`, `lighting`,\n`background`, `floor`) are a separate sparse object (`normalizePreviewSettings`): `parts`\ncontains `colorMode` (`original`, `single`, `by-part`), `color` and `colors`; `lighting`\ncontains `quality`, `exposure`, `rotation`, `size`, `fill`; `background` contains `color`,\n`opacity`; `floor` contains `enabled`, `placement`, `color`, `opacity`, `finish`.\n\nViewer appearance inherits the host; snapshots default to light. Lighting quality inherits\nPreview in a viewer and Final in a snapshot. There is no Custom state, legacy alias or\nmigration. A stored display or Preview settings record is read setting by setting at the\nfile-session read boundary (`readViewSettings`, `readPreviewSettings`): what this build\ncannot read (an older per-mode tree, a mode it no longer has) is left out, the rest kept.\nThe same display shape powers live `setDisplaySettings` and snapshot `--display`; either\nrefuses `mode: \"render\"` as it refuses any mode it does not know. A snapshot turns the grid\non with `--grid` (`display.grid`) and asks for Preview's look with `--preview` (a job's\n`\"preview\": true`, the page's own flag), at Preview's defaults. `--camera` holds\npose/framing only. A Quick Edit's views carry the mode and the grid (`display.mode`,\n`display.grid`) with Clip and Explode, so a snapshot of them replays the view.\n\n## State and updates\n\n`kit/view-settings/viewSettingsStore.js` owns one sparse display, and Preview's settings,\nper mounted file. `selectMode(mode)` selects a mode; `setGrid(on)` switches the grid (off\nis left unsaid); `patch(patch)` merges the display's shared fields. `patchPreview(patch)`\nmerges Preview's settings and `resetPreview()` removes them. `restore(display, preview)`\nreplaces state once on mount; persistence reads the latest `getSnapshot().display` and\n`.preview`. `renderScene()` resolves Preview's scene without committing anything.\n\nNo action reframes the camera, changes model pose, selection or visibility. The store\npreserves equal JSON branches; a Preview edit leaves the display's scene identity unchanged.\nClip edits do not invalidate materials, lighting, geometry or camera. No-op actions publish\nnothing. Do not merge captured props, persist resolved defaults or keep a second display\nstate.\n\nThe view injects its offered `modes` and `settings` (`edges`, `clip`, `exploded`). Unsupported\nsaved modes resolve to Solid without rewriting storage; unoffered settings do not affect the\nscene. The shared UI discovers no host APIs.\n\n## Rendering costs and camera behavior\n\nThe photographic rig stays lazy. Preview's Render creates its photographic\nsoftbox environment; the other modes use neutral CAD lighting. Authored materials are read-only. Color\noverrides are presentation settings, not edits to the model or its material\nassignments.\n\n| Lighting quality | Screen error | Shadow map | Environment | Capture scale | Samples at rest |\n| --- | --- | --- | --- | --- | --- |\n| Preview | 1 px | 2048 px | 256 px | 1× | 32 |\n| Final | 0.25 px | 4096 px | 512 px | 2× | 64 |\n\nRender is drawn through the studio's frame (core's `studioFrame.js`, see\n[the render pipeline](../../core/docs/render-pipeline.md#the-studios-frame)): ambient\nocclusion from the frame's depth, and, while the view rests, a picture that converges over\nits samples: anti-aliased, its occlusion smooth, the key's shadows a softbox's (Softbox size\nsets how soft). The viewport's loop (`useViewerRuntime`) draws each frame through it and says\nwhether anything but the camera changed (`runtime.requestRender`/`requestFrame` say so); while\nsamples are owed it asks for the next frame itself, and once the picture has converged it\ndraws nothing more. A drag, a routine's frame or a setting starts the picture again; the\ndamped tail of an orbit, moving the model less than a quarter of a pixel, does not. So\norbiting pays one draw and a few small passes a frame (about 1.5 ms more at 3024 × 1964 on\nan M1 Max) and a still view converges in about half a second. A snapshot draws its still's\n32 samples at once (video frames one). Software WebGL has no frame: one draw, as before.\n\nEach interactive viewport keeps its renderer (a view of the page's one GPU device:\n[the GPU device](cad-renderer.md#the-gpu-device)), canvas and camera controls\nacross every mode and settings edit. It uses shadow-compatible conventional\ndepth throughout, fitting near/far planes to the current model, closeup records\nand floor/grid planes on every frame; a perspective near plane stays at or beyond\n1/256 of the orbit pivot's depth, so a closeup never trades depth resolution for\na near plane at the eye. The grid's bounds also fit the far plane;\nit is independent of the studio floor used by Render. The grid\n(`lib/viewer/stageGrid.js`, one shader quad the viewer and the snapshot both draw) is graph\npaper on the floor, after Tinkercad's workplane: thin minor lines at a round step (1, 2 or\n5 x 10^n) near twenty across the default framing at density 1, a heavier major line every 5 or\n10 of them, in one cool cyan-blue ink (`GRID_LINE_DEFAULTS`: `#4ea8cc` at 0.45 on a light\nbackdrop, `#5d9ac0` at 0.35 on a dark one; minors are the same ink at 0.6 of its strength), with\na red X and a green Y axis through the origin, anti-aliased in the fragment shader. Each family\nof lines fades only once its cells crowd under about 3.5 pixels, so there is no moire zoomed\nout or at a grazing angle; the sheet is at full strength to 1.5 framings from the origin, past\nthe default view's corners, and dissolves gently by 6, and fades a little as the plane turns\nedge-on, so it reaches the edges of the view yet has no edge of its own. Display has no separate Axes setting. The grid, the stage and the Render studio's floor are\nSIZED from the model's rest placement for every renderer, STEP included: a pose, a\nplaying routine, or a pose set in Preview never rescales or\nslides the ground; its height, the lighting and the shadow reach follow the model. Enabling photographic effects updates that live scene;\nit never clears or covers the canvas with a loading screen. Only actual context\nrecovery replaces the renderer. Quality never changes a mesh: every mode draws\ncadgen's one display mesh. Picking/topology stays demand-driven in every mode.\n\nEnabling Clip prepares its shader/cap variants before presentation. Moving an\nenabled cut stays live. Explicit neutral boundaries\nstill avoid unnecessary caps. See [responsive View updates](view-updates.md)\nfor scheduling, resource caching, loading presentation and capture readiness.\n\nRender's floor stands at the document's Z=0 plane (Model origin), at 60% opacity, its opacity\nletting geometry below that plane show; Lowest point is a choice away and is remembered in Render, as Glossy is. A snapshot's Render floor stands\nwhere the viewer's does: both resolve it from the same mode default (`RENDER_FLOOR_PLACEMENT`).\nRender's floor carries a studio's soft grounding shadow, as deep\nas the floor is opaque: darkest where the model touches it, the key light's cast\nshadow softening and fading away from the model. The viewer re-renders its\nshadows, and re-bakes this one, only for a change that can alter them: never\nwhile the camera alone moves, and never for a hover, a selection or any other\nhighlight, which leave every part casting as it did (a highlighted part keeps\nits shadow). While a routine plays or a pose is dragged, the key's cast shadow on the\nfloor follows every frame and the contact darkening under the model at most every\n100 ms; once the model stops, both are baked for its final pose. A floor at zero\nopacity bakes nothing, and a viewer that renders in software, whose key casts no\nshadow, draws no floor shadow.\nIts finish is Matte, or Glossy: a glossier floor that also reflects the model, crisp\nwhere the model meets it and softening and fading over the model's height, as a\npolished studio floor shows a product, in either appearance. The reflection is a\nsecond draw of the scene at half the canvas's resolution, before each frame the camera\nor the model moved in (about 4 ms of render CPU a frame on a large assembly); a hover\nor selection keeps the last one and catches up within 400 ms. A Matte floor allocates\nnone of it and draws nothing extra, and switching back to Matte releases it. In\nsoftware, a glossy floor reflects nothing.\nIts double-sided surface uses its actual elevation during\ncamera depth fitting, avoiding the origin-placement near-plane gap. Its color\nand opacity are independent of Background.\nThe floor has no edge, as a product shot's seamless sweep has none: it is at its\nopacity around the model and fades to nothing a few model radii out, its shadow and\nreflection with it, so a low camera or a wide view shows no horizon line or corner.\nBehind it, Background is a studio backdrop rather than a flat fill: its color lifted\ntoward white behind the middle of the frame, shaded toward the corners (more for a dark\ncolor) and above the horizon. Its opacity carries the gradient; at zero it draws nothing.\nLight Render defaults to a pure white background and retains the slightly gray\nfloor (`#e7e7e5`); dark Render's background and floor are one charcoal (`#202226`), never\nblack. Explicit background colors still override the mode default.\nExternal STEP pose and animation passes publish current placed bounds before\ndrawing, and so does the exploded view wherever it comes to rest (a slider move,\nthe end of its ease) and at most every 100 ms while it eases in or out. Depth and\nlighting follow moving parts without changing camera framing or the floor's\nfootprint.\n\nAll modes share surface-under-cursor zoom, zero-pose framing and the fit\npadding: 1.1 across, and 1.1 down on a square or narrower viewport, easing to\n1.25 at 16:9 and wider (`interactiveFitPadding`), so a wide view leaves room\nabove and below the model. Mode changes do not refit, and neither does a\nrebuild of the open file. Motion changes geometry, not what 100% means. A\nchanged camera clears current screen-space drawings; camera state republished\nunchanged after a runtime replacement does not.\n\n## Where the controls live\n\nDisplay is a dropdown from the Display tile at the strip's end (the current mode's sphere,\nbetween Projection and Preview: `kit/shell/DisplayPopover.jsx`), present for every 3D file:\nthe offered modes (`DisplayModeOptions.js`), then the Grid switch. On a phone it is the\nMore menu's Display submenu, the same list. Preview, a view of its own over the editor's,\ndraws the model in Render whatever the file's mode (the store's `renderScene()`, as\n`kit/viewport/previewViewScene.js` draws it), and the tools view never does. Preview's own\nsettings are its settings panel (`kit/shell/PreviewSettings.jsx`: Orbit, then\n`PreviewRenderSettings.jsx`), its edits the file's Preview settings. A file's model tree and Position are\nsections of the file sidebar (see\n[settings-ui.md](./settings-ui.md#the-file-sidebar-and-the-tool-stack)); nothing in them is\na display setting.\n\nDisplay and Preview, the view's controls at the strip's end, are offered by a 3D view\nalone, as its renderer declares to the shell (`useRendererShell`'s `previewable`):\n\n| Renderer | Files | Display and Preview |\n| --- | --- | --- |\n| `step` | STEP, STP | Yes |\n| `glb` | GLB | Yes |\n| `mesh` | STL, 3MF | Yes |\n| `robot` | URDF, SRDF, SDF | Yes |\n| `dxf` | DXF | No: a 2D drawing, on a surface of its own, without the shell |\n\nA view without them has no Display or Preview control, not a disabled one, and a\nrequest for Preview leaves its normal view as it is.\n","isInternal":false,"tokens":3232,"sizeBytes":13466},{"name":"render-types.md","path":"packages/ui/docs/render-types.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/render-types.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Render types: capabilities and the backend contract\n\nBinding for viewer work that touches more than one file format. The rule this document\nexists to enforce:\n\n> Viewer code asks what a format **can do**, never what it **is**.\n\nEvery `renderFormat === RENDER_FORMAT.X` check is a place a new format must be\nhand-added, and a place an improvement to one format fails to reach the others. That is\nnot theoretical. The Orbit button was gated off per format independently and had to be\nfixed twice; when it was finally enabled for DXF, the button still did nothing because\n**four** separate format checks stood between it and preview mode (the toolbar gate, the\nworkspace handler bail, the pane's `previewMode={dxfMode ? false : ...}`, and an effect\nthat force-exited DXF from preview). Another format grew an entire parallel export path to\nan endpoint the server does not implement.\n\n## Scope\n\nThis is the rule inside the STEP renderer (`src/renderers/step`). A DXF,\na GLB, a triangle mesh (STL, 3MF) and a robot description (URDF, SRDF, SDF) have renderers\nof their own (`src/renderers/dxf`, `src/renderers/glb`, `src/renderers/mesh`,\n`src/renderers/robot`; see [CAD renderer](cad-renderer.md#kit)): a vertical slice owns its\ntools, tabs and scene outright and consults no capability table. The `dxf`, `glb`, `stl`,\n`3mf`, `urdf`, `srdf` and `sdf` rows below remain for what is not a renderer: the file\nlist's icon and label. The headless snapshot renderer consults no table either: it draws\neach of those families with the scene builder its renderer uses\n([one scene builder per family](cad-renderer.md#one-scene-builder-per-family-the-viewer-and-the-snapshot-cli)).\n\nThe DXF slice does not even have a scene: it is a canvas painted from\n`GET /__cad/drawing`, so none of the viewport capabilities below describes it — and its\nrow says so, with `assetKind: drawing` and no viewport tools (its own are its renderer's). `cadgen dxf snapshot` paints the same\npayload with the same code (`@text-to-cad/core/lib/drawing2d`), so the CLI cannot produce a\npicture the pane could not.\n\n## The capability registry\n\n`packages/core/src/lib/renderCapabilities.js` — one frozen table, keyed by render\nformat. Pure data: no behaviour, no imports beyond the format enum.\n\n| Capability | Meaning |\n|---|---|\n| `assetKind` | Which asset a format LOADS: `mesh`, `drawing`, `robot`. |\n| `iconKind` | The file-list glyph. |\n| `tools` | `select`, `pan`, `draw`, `orbit`, `screenshot`. Orbit and screenshot are true for every format WITH a viewport — they act on the viewport, not the geometry. `dxf` claims none of them: it has no viewport, and its tools (Select, Annotate, Measure) are its renderer's own, over a 2D canvas. |\n| `parts` | Per-part selection, hiding, isolate, assembly tree. |\n| `topology` | Face/edge/vertex references. Implies `parts`. |\n| `params` | `sidecar` (the model's `@step(pose=...)` block), or `null`. |\n| `animations` | Has animation clips, so transport controls apply. |\n| `artifactManaged` | Builds a package before it can render. A format listed here that the backend cannot produce a package for blocks forever, so a format the viewer renders from its own file belongs out. |\n\nWhat a 3D file's tools and view offer — Measure, Clip, Explode, the display modes — is not\nin this table: each renderer offers its own tools ([CAD renderer](cad-renderer.md#tools-on-a-scene-of-triangles)),\nand `viewFeaturesForKind` (`@text-to-cad/core/common/viewSettings.js`) is the one rule of\nwhat a file's view offers, for the viewer and a snapshot alike.\n\n### Rules\n\n- Add a capability when the **second** format needs it, never speculatively.\n- An unknown format resolves to the conservative default row (everything optional off).\n  Deliberately *not* `normalizeRenderFormat`, which resolves unknowns to STEP and would\n  hand an unrecognised entry STEP's full capability set.\n- Capabilities decide **which** panels and tools mount. Format-specific *content* — STEP's\n  tree — stays format-specific.\n\n## The content signal\n\n`selectedViewportContent` in the STEP renderer is the single answer to \"is there anything on\nscreen?\", its loaded mesh data. Toolbar gates, the CTA, preview mode, the viewport\ncontext menu and alert blocking all read it, rather than each one re-deriving the answer\nper format.\n\n## The render-backend contract\n\nThe kit's viewport (`renderers/kit/shell/ShellViewport.jsx`) is the shell and owns the camera,\n`OrbitControls`, the scene stage, frame presentation, the Draw overlay, screenshots and the\nimperative viewer API. A renderer's **scene** (the contract `renderers/kit/scene.js` re-exports from core; STEP's is\n`renderers/step/scene/stepScene.js`) owns geometry only:\n\n1. **Consume content**: the document its renderer loaded (a STEP's mesh data, a GLB's native\n   scene, a mesh file's objects, a robot's parts). A GLB's, a mesh's and a robot's scene is\n   built in `@text-to-cad/core`, where the snapshot CLI builds the same one.\n2. **Publish bounds** so the shared fit and its zoom baseline work. The mesh\n   path does this via `applyRuntimeModelBounds` after composing; a backend with no mesh\n   calls back with its own bounds instead.\n3. **Optionally install loop-tuning hooks** on the runtime. All are inert unless set, so\n   the mesh path is unaffected:\n   - `renderOnDemandOnly` — do not hold the render loop open for a whole gesture.\n   - `idleQualityDelayMs` — raise the idle-restore delay.\n   - `onIdleQualityRestore` — restore quality before the pixel ratio, so the expensive\n     frame and the drawing-buffer reallocation do not land on the same vsync.\n   - `resolveExtraPixelRatioCap` — cap resolution below the shared caps.\n\nA backend never reaches into the camera, controls or stage. If it needs something from\nthem, that is a shell feature and belongs in the shell where every format gets it.\n\n### Adding a format\n\nDeclare a registry row, implement a backend, add a fixture to the sweep. Do not touch the\nshell. If you find yourself adding a format check to `FloatingToolBar` or to anything under\n`renderers/step/scene/`, the capability you need is missing from the table.\n\n## Enforcement\n\nA ratcheting policy test in the repo where this app is developed counts identity\nchecks in non-test client code: the number may only go down. It also asserts a\ngrowing set of files at **zero** — the toolbar, every module of the STEP scene\n(`renderers/step/scene/`), the alert\nbuilder, the file-list icon and status, and the home screen — since those are the surfaces\nevery format flows through. Lower the budgets in the same commit that removes checks.\n\nWhat is left is deliberate. `useCadAssets` is allowlisted: choosing and running a loader\nper format is its whole job, and the `assetKind` field names *which* loader without\npretending the implementations are the same. `stepArtifactStatus.js` keeps its checks\nbecause STEP package error codes, the `stale` flag and the renderable-GLB fallback are\nSTEP vocabulary. STEP is now the only artifact-managed format: a DXF is rendered from\nthe file that is on disk — the backend flattens those bytes to 2D primitives per request\nand never owns a package for one (`owns_dxf_path` always answers False).\n\n## Standing gate\n\nThe `web` CI job's `scripts/test/test-viewer-browser.sh` opens one fixture per load path\n(STEP, STL, DXF, URDF) through the real backend and the bundled viewer and asserts each\ndraws something with no page errors: a blank-but-error-free viewport is the signature\nfailure here (a shader that fails to compile, a gate that hides the geometry). It runs on\nevery change to shared viewer code; there is no separate manual sweep.\n\n## Known non-uniformities\n\nRecorded so they are not mistaken for bugs, and so the next person knows the cost:\n\n\n## Scene recipe conformance\n\nInspect and Render resolve through `@text-to-cad/core/common/sceneSettings.js`.\nInspect's fixed light/dark workbench basis follows app appearance; Render uses\nits independent studio configuration. Legacy CAD theme preferences are not an\ninput. The internal `themeSettings` recipe remains a renderer implementation\ndetail, normalized through the shared core schema so every backend receives the\nsame lighting, materials and stage settings.\n\nThe browser qualification in `scripts/test/test-viewer-browser.sh` opens every\nload path through the real backend and bundle and checks mode framing, that a saved\nrevision keeps the camera and that Zoom to fit frames the revision; picking and the\ndisplay presets are the `packages/ui` browser specs'. Both use\nisolated generated fixtures rather than a sweep of saved theme presets.\n","isInternal":false,"tokens":2054,"sizeBytes":8608},{"name":"renderers.md","path":"packages/ui/docs/renderers.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/renderers.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# File renderers\n\nThe viewer renderers, `cad` (STEP), `dxf`, `glb`, `mesh` (STL, 3MF) and `robot` (URDF, SRDF, SDF), and\nthe kit and shell they are built on are in [CAD renderer](cad-renderer.md). They are\nthis package's renderers because every host registers them.\n\nA renderer only one host registers belongs to that host, with its own\ndependencies and tests. It uses only this package's public exports, and they are\nwhat a host renderer may rely on:\n\n- `defineFileRenderer` and the registration contract from `@text-to-cad/ui/file-viewer`\n  ([FileViewer](file-viewer.md)), including `fallback: true` for the one\n  renderer that takes files nothing else matches.\n- The file it is handed (`FileMetadata`) and its `FileSource`, which names and\n  describes files and reads nothing else: a renderer reads a file's content through\n  what its host injects into it (the CAD renderers take a `CadWorkspaceService`).\n  Every resource a renderer acquires carries a release; it returns that as the\n  prepared document's `dispose`, and FileViewer releases it when the request is\n  cancelled, the file changes, or the view unmounts.\n- The host ports in `@text-to-cad/ui/host`: `useViewerHost`, and `PromptContextAction`\n  for prompt delivery.\n- Shared chrome from `@text-to-cad/ui/navigation` (`EmptyState`),\n  `@text-to-cad/ui/primitives/*` and `@text-to-cad/ui/utils`, styled by this\n  package's `styles.css` and the host's own Tailwind build.\n","isInternal":false,"tokens":370,"sizeBytes":1434},{"name":"settings-ui.md","path":"packages/ui/docs/settings-ui.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/settings-ui.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Viewer interaction and settings design system\n\nThis is the binding contract for the viewer's tools, its file sidebar and its\nsettings, in both apps. Build from the shared pieces — the\n[toolbar button](../src/primitives/toolbar-button.jsx),\n[FloatingToolBar](../src/renderers/kit/tools/FloatingToolBar.js),\n[SubToolbar](../src/renderers/kit/tools/SubToolbar.jsx),\n[ToolPopover](../src/renderers/kit/tools/ToolPopover.jsx),\n[FileSidebar](../src/renderers/kit/tools/FileSidebar.jsx),\n[ToolStack](../src/renderers/kit/tools/ToolStack.jsx),\n[ToolPanel](../src/renderers/kit/tools/ToolPanel.jsx),\n[the floating surface](../src/lib/floatingSurface.js) and the\n[FileSheet primitives](../src/renderers/kit/inspector/FileSheet.js) — and extend\nthem rather than recreating their layout in a renderer. Environmental effects\nbelong to apps through the [host contract](viewer-host.md).\n\n## Ownership and layout\n\nFileViewer owns the navbar — the logo's app menu ([Settings](#settings)) included, built from\nthe host's `links` and the `appSettings` `CadViewer` hands it — and the file explorer (a\npopover under the file's name). RendererShell owns the scene's chrome: the tool strip and\nthe view's controls at its end (Display, Preview), the sub-toolbar of the tool in hand under\nit, the file sidebar, Quick Edit, the view cube and Preview. Renderers supply\ntheir tools, their tools' rows, their sidebar sections and their document state; the shell never\ninspects a format's parts, joints or topology. No pick or tool opens or closes the explorer.\n\n| The host (web, desktop) supplies | The shared UI decides |\n| --- | --- |\n| Where the tab record lives (`TabRecordStorage`: the web's sessionStorage, the CAD app's memory) | The record: its settings (the file sidebar's and tool stack's layout, the appearance, the home's layout) and the view of the file on screen (its camera, Display settings, its playback settings, and renderer slices), what is written when, and what is never stored (`@text-to-cad/ui/tab-store`, `kit/shell/fileView.js`) |\n| `navigation.home`, `links` (its version, GitHub, Discord, where a new issue opens, how a link opens), `appSettings` (the person's own settings), `colorScheme` (the person's theme and how it changes) | The navbar's order and look, over a file and over the home, the app menu (one menu in the viewer and on the home) and what a new issue says |\n| `host.files`, `fileActions`, `navigation`, `clipboard`, `promptContext`, `promptFiles` | When a copy, capture or open happens and what it carries |\n| `host.environment`: color scheme, keyboard `platform`, `reducedMotion` | How the chrome honours them |\n| `onError`, for errors the viewer hands up | Preview, tooltips, keyboard scope, the tool strip and sidebar, the camera |\n\nHosts pass no Preview, chrome-visibility or notification props; there are\nnone.\n\n- **Navbar.** One row, the same in every app, over every file and over the home. Left: the text-to-cad \"C\"\n  logo, named Menu, the size of the row's icon buttons, with no chevron, which opens the\n  **app menu** (see [Settings](#settings)) — a host with no home has it too — then, where the\n  host keeps the view's own history (`history`: the CAD app's views, which no browser holds),\n  **Back** and **Forward**, two arrows walking it as a browser's do, from file to file and to\n  and from the home (a step like a file), each there always and disabled where it has\n  nowhere to go; then the open file's name and its ⋯. Each of these that\n  is a control (`NAV_ITEM_CLASS`) is transparent at rest, takes the same accent background\n  while the pointer is on it and while its menu is open, has a pointer cursor and no tooltip:\n  it says what it is. The name\n  is that control where it opens the explorer (below), where the host's files can be\n  browsed; where they cannot it is plain text. The ⋯\n  is the file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host can do it, and no ⋯ where it can do none. The name has\n  no right-click menu and there are no crumbs. The left's controls sit edge to edge, spaced\n  by their own padding alone: the logo's 24px button, Back, Forward and the ⋯ in 20px ones,\n  and the name 4px in from its edges, so about 7px separate one glyph from the next. Right, only what a host or a file adds: first, the\n  update — a blue download button, there only when the host\n  found a newer release, whose menu says the step to it, how this host updates and what\n  is new. Then, while a person has put an alert card away: the card's own icon (a circle\n  with an exclamation mark, red for an error and amber for a warning, named and hinted by\n  the alert's title), which brings the card back and goes with it — the renderer's one\n  navbar action. Then, where the host shows the view small (inline in a conversation,\n  where the view is the whole viewer), its **Full size**, corner brackets. A CAD file publishes no navbar action but that\n  icon. The person's own settings, the theme among them, are never here: they are the app\n  menu's.\n\n  *Design note: the strip is for tools; the view's controls are the navbar's.* What a host or\n  the file adds (the update, an alert's icon, Full size) is the navbar's right end, and last\n  after them the view's own controls, **Display** and **Preview** (`navbarSlot`): they act on\n  the whole view, whatever tool is in hand, so they are no tools. In Preview the same place\n  holds its own controls, **Preview settings** and **Exit preview**. A host with no navbar has\n  them over the view instead: Display and Preview ending the strip after a thin rule, the\n  Preview's pair in the view's top-right corner. A new per-file button that is no tool goes\n  with them.\n\n  A host's home has the same navbar, with only the logo and its app menu (without Back to\n  files) and, where the host keeps the view's history, **Back** and **Forward** at the left\n  and, at the right end, the host's update, when there is one, and **Full size**, where the\n  host shows the view small — the same buttons, sizes and places as over a file. Under it,\n  the page: the TEXTTOCAD wordmark and byline, then GitHub (its mark, a link to the project:\n  it is open source), Discord and X, as icon links (GitHub and Discord are the app menu's\n  too, X its footer's \"Made by\"; no light/dark button: Theme is the menu's), then Recent Files with its search, grid/list switch\n  and Open where the host has a file chooser: the models opened before, pinned first, as cards\n  or rows.\n  The update shows only for a release\n  later than the version the page names, whatever the host says. A file that will not open\n  shows a page under the navbar: \"File does not exist\" and the file's path, or \"Could not\n  open that file\" and the reason, each with **Go home** where the host has a home. A view\n  drawn as a picture (`compact`, the home's thumbnails) has no navbar either, and draws the\n  model alone: no tools, view actions, cube or Quick Edit.\n- **File explorer** is a popover under the file's name (`FolderExplorer.jsx`), aligned to\n  the name's left edge, 18rem wide and never resized, as tall as its rows up to 28rem or\n  the room below it: past that its list scrolls. It floats over the view and the file\n  sidebar, so opening it resizes and moves nothing. It starts in the open file's folder and\n  lists one folder at a time — its subfolders, then its CAD files — in compact rows: one 24px\n  line of 12px text each, its kind a small light glyph in one column 5px in from the row's\n  left end and 4px before the name (a folder its disclosure chevron, with no folder icon; a\n  file its type's icon), as the file sidebar's trees draw theirs. A press on a subfolder opens it\n  inline, under itself, its rows 10px further in and hung from a faint line under its\n  chevron, read once when it first opens; another press closes it, and Right and Left do the\n  same from the keyboard. A double-click opens it in the explorer's place, as a crumb does —\n  the breadcrumb then names it. The breadcrumb is\n  as many of the last three folders as fit whole: every crumb is its folder's whole name, the\n  farthest giving way first to the \"…\" (an ellipsis icon), which lists every folder above the crumbs to jump to,\n  and only the folder it is in, left alone beside the \"…\", is cut (its name a hint while it\n  is). The folder it is in reads in the foreground color, the others muted; there is no up\n  arrow, and the open file is highlighted, the list opening scrolled to centre its row, once (a\n  long folder's too). Opened, its filter has the keyboard, so typing\n  finds a file at once; on a phone the explorer itself holds focus, keeping the keyboard\n  down. \"Filter files...\" under the breadcrumb searches\n  every file nested under the folder (the server's bounded search, debounced 150ms, each\n  keystroke cancelling the search before it); a search that stopped early says \"Stopped\n  early. Keep typing to narrow the search.\" Up and Down move between the filter and the\n  rows on screen, the rows of a folder open inline among them. A pick opens the file and\n  closes it, as do Escape and a press anywhere outside it.\n- **Tool strip** at the top centre, 8px down, never wider than the view less 8px at each\n  side (it wraps before it would be): the tools, left to right, in **two sections** parted by one\n  thin rule (a tool's `group`: the tools that pick, Select, Pencil, Shapes and Measure; then the\n  model's effects, Explode and Clip), and, where there is no navbar, the view's controls,\n  **Display** and **Preview**, directly after the last tool, in the same second section with no rule\n  between (a file with only tools that pick has its view's controls after a rule, as their own second\n  section). Where a navbar holds the view's controls the strip is the tools alone: below. 24px tiles with 14px icons,\n  an even 4px apart, a tool's chevron touching its button, in a 34px row (`FloatingToolBar`,\n  `data-cad-toolbar`). A file with no tools has no strip. On a phone\n  ([mobile](#the-file-sidebar-and-the-tool-stack)) the tiles are 36px with 18px icons, a chevron\n  20px wide, in one row that never wraps. Centre it on the viewport whenever it fits;\n  clamp its position to keep 8px from the edges and from the file sidebar's tile at its left.\n  Its available width still uses all the room after that tile. What does not fit goes, from the\n  strip's end (the view's controls where the strip has them, then the tools from the last), into **More tools** (an ellipsis, last), a tool a row ticked while it is\n  in hand and Display opening its popover from More (`touchStripFit`).\n- **Sub-toolbar** under the strip, 8px below it, centred: the tool in hand's one row of\n  controls (`SubToolbar`, `data-cad-sub-toolbar`), there for as long as the tool is up (see\n  [Tools and lifecycle](#tools-and-lifecycle)). Select and Measure have none. On a phone its\n  buttons are the strip's 36px tiles and its sliders as tall, the slider rows 20rem wide.\n- **Tool stack** under the sub-toolbar, in the same centred column: a renderer's own\n  `toolPanels`, where it hands any (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)). The column\n  stops 8px above the cube's line (`VIEWPORT_STACK_BOTTOM`), so it never runs past the\n  view.\n- **File sidebar** at the top-left, 8px in: a toolbar of tiles, one per section of what there\n  is to read of the file — a STEP assembly's Model tree, a robot's Links, Position, an SDF's\n  metadata, the routines' Animation — whatever tool is in hand; a tile opens its section in a\n  panel under the tiles, one at a time, down to 8px off the view's bottom at most. On a phone it\n  is one tile, opening one panel with the sections as tabs (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)).\n- **Quick Edit** at top-right, 8px in, a STEP's or a robot's: the box of the note a\n  person writes to their agent, there only while something is picked, drawn or measured\n  (see [Quick Edit](#quick-edit)). The rest of the right side of the view is clear.\n- **View cube** at bottom-right, 2px from the right and 8px off the bottom so the axes\n  drawn in its lower corner never touch the edge (`VIEWPORT_CUBE_BOTTOM`), in a 6rem area:\n  enlarged face/edge/corner hit areas and neutral hover and XYZ guides. A host whose own\n  control floats over that corner lifts it clear with `--cad-viewport-corner-bottom`, the\n  room that control takes from the bottom edge. A host can instead anchor it at bottom-left\n  with `--cad-viewport-corner-left: 2px` and `--cad-viewport-corner-right: auto`.\n  Codex's tab presentation uses this for all its views to avoid the floating composer.\n  Preview omits it, and so does a phone.\n- **Display and Preview** end the navbar, for a 3D file (`data-view-controls`, the box\n  FileViewer hands the renderer as `navbarSlot`): **Display** (the active mode's sphere: see\n  [Display settings](#display-settings-and-section-primitives)) and **Preview** (a play\n  triangle, the way into Preview), the navbar's icon buttons, hinted below them. They are no\n  tools: pressing one leaves the tool in hand as it is. Display's dropdown opens down from its\n  button, aligned to its end, never taller than the viewer below it. A view that is not 3D has\n  none of them, and neither does a view drawn as a picture or one that is loading or failed to\n  load; in Preview the same place holds its own controls (see\n  [Preview](#camera-animation-and-preview)). With no navbar (a host showing the view\n  alone) they end the tool strip after its rule, and Preview's stand in the view's top-right\n  corner.\n- **Nothing sits at the bottom centre**, in the tools view or Preview: a file's routines\n  play from the file sidebar's Animation section (see [Preview](#camera-animation-and-preview)).\n- **Model update status** sits under the strip and its sub-toolbar, centred, and where\n  there is no strip (Preview, a compact view, a load) at the top centre, in the strip's\n  34px row, in every host. It is renderer chrome: the navbar carries no status. On mobile\n  it is a tappable progress icon whose popover names what is loading.\n\n## Tools and lifecycle\n\n| File | Tool strip, left to right |\n| --- | --- |\n| STEP | Select, Pencil, Shapes, Measure; Explode (two or more parts), Clip, Display and Preview |\n| URDF / SRDF / SDF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| GLB | Select, Pencil, Shapes, Measure; Explode (several rigid nodes that draw), Clip, Display and Preview |\n| 3MF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| STL | Select (in hand, the view's navigation: an STL picks nothing), Pencil, Shapes; Clip, Display and Preview |\n| DXF | Select (in hand, and the view's hand: a drag pans, a tap picks an entity), Pencil, Shapes, Measure; no view controls: a drawing is 2D |\n\nThere is no activity dot. A thin rule parts the tools from the view's controls, which\nare no tools (see [Ownership and layout](#ownership-and-layout)). Position is no tool:\nits knobs are Select's, while Select's filter picks Position, and its controls are a\nsection of the [file sidebar](#the-file-sidebar-and-the-tool-stack), and so is Animation, where\nthe file's routines play (see [Preview](#camera-animation-and-preview)). A file with no tools\nand no 3D view has no strip at all.\n\n**A tool the file cannot offer is not on the strip**: never shown disabled.\nRenderers leave it out of the `tools` they hand over; `FloatingToolBar` draws\nwhat it is given. Until a file has loaded, what it offers is not known: a tool\nthat depends on it (Explode) is shown idle meanwhile, a robot's filter offers\nPosition meanwhile, and the whole strip is disabled while loading. Unavailable and\nidle are different states.\n\nOne tool owns pointer input, and it alone is lit on the strip. An effect a person set —\nan explosion, a cut, a pose — stays on the model once its tool\nis put down, with nothing on screen for it but the model. **A press on the tool in hand\ndoes nothing**: a tool is put down by taking up another (Select, most often), or by Escape, which\ntakes Select up. A press on\nExplode or Clip with its effect set takes it up again, as it is; unset, it starts neutral.\n\n| Tool | Pressing it | Its sub-toolbar | Leaving it |\n| --- | --- | --- | --- |\n| Select | The default tool of every 3D file but an STL; picks what its filter ticks, or a GLB's nodes, a 3MF's objects (Shift adds; Delete deletes annotations and measurements); what is picked is Quick Edit's chips | None: its filter is the chevron after its button | The selection stays, lit and in Quick Edit, under any tool |\n| Pencil / Shapes | Draws on the view: Pencil with the pen, the highlighter or the eraser, Shapes with the rectangle, line, arrow, ellipse or text; each button is the tool last chosen in it | One row for both: the tools of the one that is up, then Color, Stroke width, Placement and Clear annotations (Undo and Redo are ⌘Z, ⇧⌘Z) | The annotations stay in the model, their chip in Quick Edit; each tool keeps what it draws with, the strip showing its icon |\n| Measure | Arms picking two entities and reading what the pair makes (a STEP's exact vertices, edges and faces, what its filter ticks; on a mesh, two points on its surface, approximately, in its file's unit; a drawing's ends, centres and lines, in the unit it declares, else \"units\"); each result a chip in its colour | Its measurements, as chips, then Clear measurements; none while nothing is measured | Unfinished picks are cancelled; completed measurements stay, drawn on the model, their row back with the tool |\n| Explode / Clip | The effect as it is set, or neutral; an edit applies it (Explode's, as a motion: see below) | The slider, its amount beside it, Reset (Clip's: its axis first, whose dropdown holds its settings) | The effect stays on the model |\n\n**One motion at a time.** Every tool's state stands beside every other's — the selection,\nmeasurements, the annotations, Clip — except the two that move the parts: **Explode** and\nthe **Position** pose. They replace each other: taking up Explode puts the pose back at its\ndefault, and using Position (a knob dragged, a value set or a named pose chosen) takes\nExplode off, with nothing set aside to come back. While the model is exploded there are no\nknobs. Put down, Explode keeps its explosion under any other tool until Position is used. A\nroutine played from the Animation section is the third motion: playing it puts Explode and the\npose back at rest, and using either puts the routine back at rest; paused, its frame stays on the\nmodel as a pose does. Preview's routine is its own and moves nothing of the tools view's.\nAnother file has the motions its tools have, under the same rule, decided by value\n(`kit/tools/model-effects/motionExclusion.js`): a GLB's Explode, routine and Clip; a robot's\nPosition and Clip.\n\nA tree is the file sidebar's, on screen under every tool; a pick in it takes Select up\nbefore it acts.\n\n**Removing an effect.** Explode's and Clip's sub-toolbars end in\nReset (\"Reset explode\", \"Reset clip\"): always there, disabled while there\nis nothing to take away, it removes the effect — the amount back at zero, nothing else (Clip's\naxis and its settings stay as set) — and leaves the tool up. Position's way back is its Pose row's Default. A\nmeasurement's chip X deletes it, and Measure's Clear measurements all of them; the Annotation chip's X clears the annotations.\n\n**Select and Measure are the tools with a menu on the strip** (Measure's on a STEP only), each its selection filter: the\nchevron right after the button (`ToolSettingsMenu`'s \"chevron\" trigger, `kit/tools/ToolModeMenu.jsx`),\nas tall as the tile and touching it. The button and its chevron are **one control**\n(`FloatingToolBar.js`): one rounded fill lights the pair on hover, while the tool is in hand and\nwhile its menu is open, and the chevron's half only brightens over it, on its own hover and\nwhile its menu is open, with no line between the halves. A change\nto a filter takes its tool up. A press on any other tool is its only action. Whatever a tool can\nbe set to while it is up is its sub-toolbar. A dropdown is ordinary — under its own button,\nfree to overlap the view — and closes with no exit animation, so a quick second tap (touch\nincluded) always reaches its trigger. Choosing a value closes it; ticking an option leaves\nit open, as do Clip's axes and the app menu's Theme. Menu checks sit on the right. The other dropdowns over the viewport are Display,\nthe sub-toolbars' (Clip's axis and settings, the drawing tools' colour, stroke width and placement), its\ncontext menu, and the Animation section's routine and Playback settings (`PlaybackMenu.jsx`, on\n`ToolPopover`). Preview's settings are a panel, not a dropdown (see [Preview](#camera-animation-and-preview)).\n\n**Select's menu** (`kit/tools/select/SelectFilterMenu.jsx`) is a CAD tool's selection\npriority (Fusion's Select > Face Priority, Blender's vertex, edge and face modes), not a\nchecklist: the chevron's dropdown, headed \"Pick\", lists ONE choice of what a click on the model\ntakes, each with its glyph — **All** first, the default, then each kind alone. Choosing\none puts the menu away and takes Select up. A STEP's (`SelectionModes.jsx`'s `stepSelectModes`):\n**All** (the pointer: a face or an edge where a part's are loaded, the part elsewhere,\nPosition's knobs, the annotations and the measurements), **Parts** (a whole cube; an assembly's\nonly — in a part file a press that reaches no face or edge picks the whole part), **Faces** (a\nfaint cube, its top face filled), **Edges** (a faint cube, one edge heavy) and **Position**\n(where the file has joints to move: its knobs alone, the model picking nothing). A robot's:\n**All**, **Links** and **Position** (where a joint can be driven; a robot with nothing to\ndrive has no menu). The annotations and measurements are no choice of their own: they are drawn\nover the model, so a click on one is never taken for the face under it, and a kind alone (Faces)\npicks that kind only. All is chosen when a file opens; the choice holds while the file is\nopen, across updates of it, and another file starts on All. The chevron's name says what\nis chosen (\"Selection filter: everything\", \"Selection filter: Faces\"), and Select's button wears\nit as a badge on the pointer (`modeGlyphs.jsx`'s `ToolModeIcon`; none for All), so what a\nclick takes is on the strip, not only in the menu. After a rule, a STEP's menu ends in how a face\nor edge pick grows, each with its glyph and independent of each other: **Group edges** (the\nconnected edges with a picked edge), shown while edges are picked, and **Group faces** (the\nsmoothly joined faces with a picked face), shown while faces are; a hidden one keeps its choice\nfor when it shows again.\n\nThe geometry chosen is picked as one mode (`step/workbench/selectionFilter.js`'s\n`selectGeometryMode`): All, a press takes the face or edge it lands on where the part's\ntopology can be picked, and the part elsewhere; Parts, the part; Faces or Edges, that topology\nalone. The mode sets the model tree's shape: under All, or Position (no geometry at all),\nit is the person's own (put back as it\nwas when they left it, with the owners of what is still selected kept open); Parts alone\nopens every assembly and shuts every part; Faces or Edges open every assembly. A\npart is a leaf in every shape: nothing opens under it, and a face or edge picked in the\nviewport is revealed at its part's row. Outside the person's own shape the disclosure is\nlocked (chevrons shown, not pressable) and Expand/Collapse leave the row menus. Under Faces\nor Edges a part's topology is asked for as its row comes on screen in the tree, never for a\nwhole large assembly at once; a viewport press on a part not yet loaded loads that part and\npicks, and the model tree's heading says \"Loading…\" meanwhile. In a large assembly (more than 300\nrows of assemblies and parts, `LARGE_TREE_ROWS`) a part's row coming on screen asks for\nnothing: the pointer resting on the part in the viewport (150ms) or pressing it asks.\n\n**Sub-toolbars** (`kit/tools/SubToolbar.jsx`): the tool in hand's controls, one row hung\nunder the strip, centred, 8px below it, on the strip's surface, its buttons the strip's\n24px tiles, its groups — what the tool does, then how it looks and what clears it — parted by a\nthin rule. It stays up for as long as its tool does, and has no heading: the tool's lit\nbutton above names it. Explode's and Clip's are one shape — what leads\nthe slider, the slider, its value right beside it and only as wide as\nits text, the tool's actions (neither has any), then Reset, last, at the same spot in both,\nwith no rules — and one width (`SLIDER_SUB_TOOLBAR_CLASS`, 202px; a phone's 320px). The slider\ntakes the rest of the row. A tool's options never sit in the row as checkboxes, labels or\nbuttons of their own, which would squeeze the slider: they are the dropdown of what leads it\n(Clip's axis).\n\n**Pencil and Shapes.** Drawing is two plain strip tools, a button each, with no chevron and no menu:\n**Pencil**, drawing with the **Pen** (the freehand ink, tapering with pressure or\nspeed), the **Highlighter** or the **Eraser**; and **Shapes**, with the **Rectangle** (what it opens on), **Line**, **Arrow**,\n**Ellipse** or **Text** (a mark put down in place). The tool's button shows the icon of what it draws with\nnow, and a press takes up the one last chosen. Pencil and Shapes share the annotation and its history, whichever is up, but not the style: the colour, stroke width\nand placement are kept **per drawing kind** — the Pen, the Highlighter (which opens in neon yellow) and Shapes (all five\nshapes share one) — and the row shows and sets the tool in hand's own — and **one sub-toolbar**,\n\"Annotation controls\" (`annotation/toolbar.jsx`'s `AnnotationToolbar`), a single row, never a\nsecond, from the left: the **tools of the one that is up**, a button each, the one in hand pressed (with Pencil up, Pen,\nHighlighter, Eraser; with Shapes up, Rectangle, Line, Arrow, Ellipse, Text: choosing one is how the strip tool changes what it\ndraws with); a thin rule; then what is drawn next, each a dropdown whose button shows its value, no\nchevron: **Color** (no heading, a grid of swatches, unnamed but for assistive technology: the neons and\none neutral that reads against the viewer, white in dark mode and near-black in light, then\nthe colour wheel, which opens the system's colour picker for any other colour, kept once the\npicker closes), **Stroke width** (no heading, four weights, 1, 2, 4 and 6px, the pen drawn to look the\nsame weight; on the button and as each row, a straight line drawn at that weight, unnamed but for assistive\ntechnology, the current one checked) and **Placement** (laid out as the strip's menus are: headed with its name, a\nrow per choice, the check on the right: **On surface**, the default, its glyph a sphere: each point of the next mark on the visible\nsurface under it; **On plane**, its glyph a sheet: the whole mark on the plane of the surface it\nstarts on; a flat view, a DXF, has none); then **Clear annotations** (the trash), disabled while there is\nnothing to clear. Under the **Eraser**, which draws nothing, Color, Stroke width and Placement are disabled (greyed out, still\non the row so it does not jump). Undo and Redo have no buttons: they are ⌘Z and ⇧⌘Z (Ctrl+Z, Ctrl+Y). The row is the same row for both tools at every width; on a phone its\nbuttons are 36px tiles and, where the row is wider than the view, it wraps rather than scrolls (there is no second menu on\nthe strip for it). The **Highlighter** draws a freehand stroke like the pen's, but broad and translucent: five times\nthe chosen stroke width, 35% opaque, round at its ends, with no pressure taper; it is an ordinary stroke, picked,\nerased and recoloured as any (its Stroke width in the selection's bar is the same setting). The tools have no Select or Pan of their own (Select picks\nannotations, a right drag or Space orbits). Never a restyle of ink already down: the\nannotations Select picks have their own bar, a small floating toolbar on the tool strip's\nsurface just above them — Color and Stroke width dropdowns, Fill for a closed\nshape, a rule, Delete. An update of the model forgets the annotations too, their history with\nthem (Undo has nothing to bring back), and leaves the tool in hand, with its tool, colour,\nweight and placement. The pen and the shapes share one default stroke\nwidth. The annotations have no panel: while there are any they are Quick Edit's\n**Annotation** chip (its X clears them), and while Pencil or Shapes is up the copy shortcut writes\nthe view with its annotations to the clipboard as a PNG. A first stroke opens\n[Quick Edit](#quick-edit), whose header says **annotation** while there is ink. The drawing tools\ndisable the cube without hiding it.\n\n**Measure.** Its button is the ruler, badged as Select's is with what it\npicks alone. On a STEP, its menu is Select's own (`kit/tools/measure/MeasureFilterMenu.jsx`):\n**All** (when a file opens), **Vertices**, **Edges** or **Faces**, one chosen. A press takes an\nENTITY, whole, as a CAD measure tool does: an edge's end within 10px of the pointer is its\nvertex, else the edge, else the face (`measureFilter.js`). What lights under the resting pointer\nis what a press takes, with what it is worth on its own beside it (a vertex's X Y Z, an edge's\nlength or ⌀, a face's area). With one kind chosen, only that kind is taken: an edge's end is still\nits vertex under Vertices, and anywhere else a press takes nothing. Shift held, a press takes the\npoint on the model as it is. In an assembly, a press on a part whose faces and edges are not loaded asks\nfor them and takes the pick there once they arrive.\n\nThe reading follows from the pair (`@text-to-cad/core/lib/viewer/measureEntities.js`), not from\nwhere on each the pointer happened to be:\n\n- two vertices: the distance, with ΔX ΔY ΔZ;\n- parallel faces, parallel edges, a vertex to a face or an edge: the perpendicular distance, \"⟂\";\n- a hole (a circular edge or a cylinder) to anything: from its centre or axis, so two holes\n  read \"C-C\" and a hole to a face its distance from that face;\n- faces or edges at an angle: the angle, \"∠\", the acute one;\n- anything else: the shortest distance between the two.\n\nA pair with no gap (an edge in its face, two flats at one height) reads as the distance between\nthe two points picked. A press on the entity already held lets go of it, as does a press on\nnothing. On a mesh there are no vertices, edges or faces to take: a press takes the point on its\nsurface under the pointer, and the menu goes. A drawing's Measure has no filter.\n\nIts sub-toolbar is its measurements (`kit/tools/measure/measurementChips.jsx`'s `MeasurementsBar`),\nthere while Measure is the tool and something is measured, and no row at all while nothing is:\neach completed measurement a chip, Quick Edit's own (`ReferenceChips.jsx`, a few then \"+N more\"),\nthe ruler in the measurement's colour (the colour of its line on the model) and its reading; its\nX deletes it, resting on it lights its line and labels it, and its card, opening under it, is the\nfull reading (what the pair makes, the angle or the shortest distance with it, the delta along\neach axis between two points, what each end bound to); then a rule and **Clear measurements**.\nMeasurements are the person's alone: none of them goes to the agent, in Quick Edit or its\nsnapshots, and measuring never opens Quick Edit. They stay, drawn on the model, under every other\ntool; a press on Measure while it is up does nothing.\n\n**Explode and Clip** have no enabled checkbox, and their sub-toolbars are drawn alike.\nExplode's: its slider, the amount (a percentage), **Reset explode**. Clip's: its axis — a\nborderless button reading the axis in hand, \"X ⌄\", lit while its menu is open — its slider, the\namount, **Reset clip**. The axis's dropdown holds all of Clip's settings, on Display's surface\nand in its type, as narrow as its content, with no heading and no glyphs: first X, Y and Z, a\ncompact group of the app menu's Theme buttons (24px squares, its radius, no track) in a row as\ntall as an item (a phone's 40px, as Theme's row), the axis in hand in the accent a highlighted\nitem wears; a rule; then two of Display's `DropdownMenuCheckboxItem`s, plain text with the check\nat the right: **Part colors** (ticked by default: each section cap in its\npart's normal colour, without hover or selection tints; unticked, every cap one amber) and\n**Reverse** (unticked by default: which side of the plane is kept). Caps stay opaque, a\ntransparent part's too. A press on any of them leaves the menu open, and none of them applies a\ncut; on a cut, a new axis or Reverse turns it keeping its share. They are settings of the\nfile's view (`clip.axis`, `clip.partColors`, `clip.invert`, stored and restored with its\ndisplay), kept across tools, by Reset clip and by taking up an unset Clip. Explode opens at 0%, Clip at no\ncut; an edit applies the effect, and the slider back at zero removes it. An axis alone is not\nan effect. The view settings own both effects, so a mode reset preserves them and a restored\neffect reappears on the model. Clip's slider, left to right, cuts deeper through the original\nbounding box; pose, animation and Explode never redefine the range. It shows the share cut\naway, while the view stores where the plane stands (`clip.at`, in millimetres), so a model\nedit leaves the plane where it was and the slider reads 0% or 100% if the plane ends up\noutside the new box. Explode is offered only where there are parts to separate: a STEP\nassembly and a GLB of several rigid nodes. A STEP part file has no Explode tile, as it has no\nModel tree (`STEP_PART_VIEW_FEATURES`).\n\n## The file sidebar and the tool stack\n\n**The file sidebar** (`kit/tools/FileSidebar.jsx`) holds what there is to read of the file,\nwhatever tool is in hand — the strip is for using tools, the sidebar for reading the file: a STEP\nassembly's **Model tree** (a part file, whose tree would be its one row, has none), then\n**Position**, where the file has joints to move; a robot's **Links**, an `.sdf`'s **SDF**, then\n**Position**, where a joint can be driven; a drawing's **Layers**, where it has four or more\nlayers or any block reference (a flat pattern's CUT and BEND has none): each layer's pen, name,\nentity count and eye, its block references under it; and, after them, the shell's own **Animation** for a\nfile with routines (`kit/tools/playbar/AnimationSection.jsx`). A file with none of them has no\nsidebar. It is a toolbar of its own at the view's top-left, 8px in, on the strip's line and of\nits surface, padding and 24px tiles (`data-file-sidebar-tools`, \"File sidebar\"): one tile per\nsection, a tree's a tree icon named for it (Model tree, Links), Position's a spline, Animation's a\nclapperboard, any other section's a page named for it. Preview offers its Position and\nAnimation alone, their tiles bare in its top-left corner; the rest stay mounted, out of sight.\n\n- **One section at a time.** None is open as a file opens. A tile opens its section in a panel\n  under the toolbar, 8px below it, and is pressed while it is open; another tile opens its\n  section in the panel instead; a second press of its tile closes it, and so does the X that\n  ends the section's head. The panel slides in from the left as a section opens with none open\n  before, and back out as the last closes (200ms, none under reduced motion); from one section to\n  another it stays where it is. Closed, the panel is out of reach (`inert`). Every section stays\n  mounted, open or not, so a tree keeps its expansion, search and scroll. Nothing folds: a\n  section is its head and its body.\n- **Sections.** Each section is a `ToolPanel` drawn as one: a 24px head — its name 12px in from\n  the panel's edge, what its body puts there (`ToolPanelHeadAction`: Position's and Animation's\n  Reset), then its X — and its body. A tree's head is its filter instead of its name, the X\n  after it. The section is its content's height and the panel as tall as it is, up to its cap;\n  past that the whole panel scrolls, as one, the head pinned to its top\n  (`data-pinned`). A tree's rows are mounted as that scroll brings them into view\n  (`kit/inspector/VirtualRows.jsx`).\n- **Filter.** A tree is headed by its filter (\"Filter model\", \"Filter links\": \"Filter…\"), in place of a title,\n  always there, a tree row's 24px and 11px text, its icon over the rows' glyph column\n  (`primitives/tree-filter.jsx`'s `TreeFilterInput`, `dense`), its X clearing it once something\n  is typed, Escape too. The model tree's filter is followed by \"Loading…\" while a part's faces load\n  and **Show all** while parts are hidden. Every tree's search is one ranked flat search\n  (`kit/inspector/modelTreeSearch.js`).\n- **Size, each section's own.** A section's panel opens a tool panel's width\n  (`TOOL_PANEL_WIDTH`, 164px) and as tall as its section, capped at the room under the toolbar.\n  It is sized from its bottom-right corner alone, by Quick Edit's grip (`kit/tools/ResizeGrip.jsx`):\n  width and cap together, by pointer, or by keyboard (a separator named \"Resize file sidebar\":\n  Left/Right nudge the width and Up/Down the cap by 16px, Home and End take both to their\n  bounds). Its width runs from 120px (`FILE_SIDEBAR_MIN_WIDTH`: room for a heading's name and its\n  buttons; a tree's names truncate) to half the viewer, its cap from 64px to its room. One write,\n  when the gesture lets go (or per key), never per pointer move; each section keeps its own size\n  (`sidebar:<id>`), so the model tree may be wide and tall while Position stays narrow.\n- **On a phone** the sidebar is one touch tile, **Sidebar** (a panel icon,\n  `data-file-sidebar-toggle`), at the left of the strip's line, the strip padded clear of it on\n  both sides and keeping to its tools (`FileSidebar`'s `phone`). It opens one panel on the section\n  last open (the first, at first): its top a row of tabs, one per section with its tile's icon\n  (shadcn's `ToggleGroup`, `data-file-sidebar-tab`), and the panel's one X at that row's right end\n  (a section's head then has none; a panel of one section has no tabs and keeps its head's X).\n  The panel runs down to the view's bottom inset, or is as tall as its section if that is less,\n  stopping above Quick Edit's peek (`--cad-quick-edit-peek`), at a phone's own width (288px,\n  within the view's inset), with no grip. Its text is the panel's one size (13px, as the\n  Display menu's), its tree rows 36px with 16px glyphs, its filter, dropdowns and value fields\n  32px (`globals.css`). In Preview its tiles are its top-left's, as on a desktop.\n- **The layout is the person's.** The section sizes are one of the tab's settings, across its\n  files (`CadPreferences.toolStack`: `{ panels: { [id]: { width?, height? } }, collapsed: { [id]: boolean } }`,\n  a section's panel under `sidebar:<id>`, kept beside the appearance; any other key a stored\n  record carries is dropped). `panels` holds only what a person set; `collapsed` is the tool\n  stack's folded panels. Which section is open is not kept: a file opens with none. A new tab (a\n  cleared record) puts everything back at its defaults.\n\n**The tool stack** (`ToolStack`) is the column under the strip and the sub-toolbar, centred\nwith them, for a renderer's own `toolPanels`. STEP and the robot renderer hand none: their\ntools' controls are sub-toolbars and what there is to read of their files is the sidebar's.\nIts panels follow these rules:\n\n- **Two kinds of panel.** A `resizable` panel is the person's to size, on its own; every\n  other panel is *fixed*: one width, its content's height, and no grip.\n- **One width.** Every panel opens at `TOOL_PANEL_WIDTH`: 164px, a strip of six tools (six\n  24px buttons, 2px gaps, 4px padding and a 1px border). A fixed panel is exactly that wide; a\n  resizable one is only ever made wider, up to half the viewer. The panels hang left-aligned\n  in the column, each at its own width. Content truncates to fit; it never widens a panel.\n- **Heights.** A panel is exactly its content's height — never padded to a minimum. A\n  resizable panel has a *cap* the content grows up to and then scrolls inside: it opens capped\n  at the stack's own height (`toolPanelDefaultHeight`). A cap is never a floor.\n- **One grip, on a resizable panel only**, the sidebar's: its bottom-right corner, Quick\n  Edit's grip, moving its width and cap at once, a separator in the tab order named after its\n  panel, keyed as the sidebar's is. A folded panel has no grip: there is no height to set.\n- **Never past the viewer.** The column stops 8px above the cube's line\n  (`VIEWPORT_STACK_BOTTOM`, `calc(6rem + 16px)`). When the panels need more, a `\"tree\"` panel\n  gives way first, down to 128px or its content, then the `\"details\"` panels together, each in\n  proportion to its height, down to 96px or its content; a `\"fixed\"` panel keeps its height.\n  If what cannot give way still does not fit, the column itself scrolls — a panel is never cut.\n- **Headings.** A panel's heading row reads as Display's section headings do (11px, regular,\n  `TOOL_PANEL_HEADING_TEXT_CLASS`), 28px tall, 8px in: a title, a summary where there is one,\n  then at its right end its own actions, the chevron where it folds (up while open, down while\n  folded) and an X where it has something to remove. Every small button in a heading is 20px,\n  2px apart and 4px from the edge. A panel can end in one full-row button under its body,\n  which never scrolls (`footer`, `ToolPanelFooterButton`).\n\n- **Surfaces.** Three, defined once (`lib/floatingSurface.js`), with one border: the\n  tool strip, its sub-toolbars and the stack's panels, which stay up beside the model, share\n  `FLOATING_CHROME_SURFACE_CLASS` — the background at 30% and barely blurred\n  (1px), so the model behind them is easy to make out; every menu and popover\n  over the viewport, the app menu and Display included, share `FLOATING_SURFACE_CLASS` (the\n  background at 75%, blurred), so their text never competes with the model; and the file\n  sidebar, mostly text but up for as long as the file is open, is between them\n  (`FILE_SIDEBAR_SURFACE_CLASS`: 55%, a 4px blur).\n- **Scrolling.** Every scroll region in the viewer's chrome — the file sidebar, a panel's\n  body, the stack's column, the explorer, a menu, the alert card — is the\n  `ScrollArea` primitive (`primitives/scroll-area.jsx`, shadcn's): thin overlay\n  bars in the theme's colours, shown while the pointer is over the region. No\n  native `overflow-auto` scroller in chrome; `src/designSystem.test.js` holds it.\n  The home (the model library) is a page, not chrome: it scrolls as a page does,\n  with the platform's scrollbar, and only when there is more of it than fits.\n- **Nothing opens elsewhere.** A pick shows as Quick Edit's chips; the sidebar is on screen\n  under every tool. No pick or tool opens or closes the explorer, on desktop or mobile.\n\nTree rows inset their backgrounds 4px from the panel's edges, and the search box\nshares that inset. A tree in the file sidebar (Model tree, Links) is dense (`TreeRowSurface`'s\n`dense`): 24px rows in the panels' 11px text, and ONE light 12px glyph per row in one\ncolumn, as the explorer has: a subassembly's or folded group's disclosure chevron, turned down\nwhile open, or a part's box (Links draws its chevrons alone), 4px before the name. The glyph\ncolumn starts 5px in from the row's left end (`TREE_ROW_INSET_PX`), centred under the find\nfield's search icon and as far in as the icons of a row's actions sit from its right end, in the\nSTEP tree and the explorer alike; each level adds\nthe explorer's compact indent, 10px (`TREE_INDENT_PX`), its rows hung from a faint line under\ntheir owner's glyph (`TreeRowGuides`), as the explorer's are; its search box is its section's head. An assembly row's\nactions, shown on hover and kept while they are on, are **Isolate** then the **Hide/Reveal** eye; a part file has no\nIsolate. A Links row that draws something — a link with visuals, or one visual —\nhas the eye alone, in its search hit too: a link's hides its own visuals, never its\nchild links, and on a partly hidden link hides the rest. They float over the row's right end rather than taking width from it:\nthe name runs the row's full width and, while an action shows, fades out half a\nrem before them (a mask, `ROW_NAME_UNDER_ACTIONS`), so nothing is drawn behind the\nbuttons and the row keeps its own colour.\nModel and link finds and the explorer's filter share `TreeFilterInput`; the explorer's rows are compact\n24px lines, as the trees' are, but in 12px text.\n\n**Mobile** is below 600px of FileViewer width (Material's compact window class) — the one viewer breakpoint\n(`useViewerMobile`); chrome never uses window breakpoints, nor the pointer's kind: a small window\nis worked as a phone is. What a finger reaches is the gesture's own (`pointerType`): a touch takes an\nedge from 20px off (an edge within 16px wins over the face under it), ink, a measurement and the\neraser's ring from 16px (`TOUCH_HIT_PX`), and a joint's knob from 22px; a mouse keeps its few px.\nOn a phone every slider is a 36px band with a 20px thumb, and the selected ink's bar has 36px\nbuttons. A pinch zooms at the touch speed from the first gesture after a load. A phone is read at arm's length\nand worked with a finger, so its chrome is one step up the scale, after the platforms' own\nguidance (Apple's 44pt and Material's 48dp touch targets, an 11pt type floor and 15-17pt body\ntext, and iOS Safari zooming into any field under 16px): type 12, 13, 14 and 15px for `text-micro`,\n`text-tiny`, `text-xs` and `text-ui`; text entry 16px; menu, select and tree rows 36-40px; the\nnavbar a 48px row of 36px controls; the strip and the sub-toolbars 36px tiles (the strip one row,\n**More tools** holding what does not fit; the drawing tools' row the same single row, wrapping where it must).\nThe scale is set once, in `globals.css`, for the whole page while a phone's view is on it, so a\nportalled menu, select or hint is the same size as the chrome it opened from. There is no view\ncube; the file sidebar is one tile at the strip's left, its sections tabs of one panel; and\nQuick Edit is a bottom sheet (see\n[Quick Edit](#quick-edit)). The explorer is the same popover, never wider than the window less\n1rem. The shortcut hint is hidden.\nTouch works for every tool: one finger orbits, two pan and\nzoom, a tap picks; a pinch, a cancelled pointer or a camera drag is never a\npick.\n\n## Display settings and section primitives\n\nDisplay is not a tool. Its tile ends the strip after the strip's rule, between Projection and\nPreview, and shows the current mode's sphere icon. It opens a stock shadcn dropdown menu\n(`DisplayPopover.jsx`): a muted **Display** label, the modes as a `DropdownMenuRadioGroup`,\na separator, then **Grid**, a `DropdownMenuCheckboxItem`. On a phone the strip's **More**\nmenu has a Display row whose nested submenu is the same list (`DisplayMenuItems`), with no\nlabel. Checks sit at the row's right end. Choosing a mode closes the menu; ticking Grid\ntoggles it and leaves the menu open (`onSelect` prevents the close). Clicking outside or\npressing Escape closes it. Opening it leaves the active tool and its controls in place. A\n2D view has no Display or Preview control.\n\nDisplay is a choice of mode and the Grid switch, and nothing else: a mode has no settings\nof its own, and there is no settings page. The modes, in order, are **Solid** (shaded with\nthe model's CAD edges, the default), **Shaded** (no edges), **Parts** (Solid with each part\nits own colour), **X-ray**, **Hidden line** and **Wireframe**, filtered by the view's\ninjected capabilities (`viewFeaturesForKind`, `DisplayModeOptions.js`): a STEP assembly\noffers all six, a STEP part file all but Parts, and a file without CAD edges (STL, 3MF, GLB,\na robot) Solid, with Parts when it has several parts, objects or links. **Grid** draws the\nmeasuring grid in every mode, off by default. Each row has its icon, the sphere drawn as the\nmode draws a model (and a tile of floor grid for Grid). Render is no display mode: Preview\ndraws the model in Render whatever the file's mode, with its own settings (see\n[Preview](#camera-animation-and-preview)). Per-file storage keeps the mode and the grid;\nold-shaped stored display settings are dropped setting by setting, never migrated.\nProjection is a menu of its own, shared, orthographic by default; Preview's Render resolves\nto perspective while preserving the shared choice.\n\nPreview's settings panel (`PreviewSettings.jsx`, its body `PreviewRenderSettings.jsx`) is\nthe one settings form: Orbit, then Render's Part colours, Lighting, Background and Floor.\nIt shows all groups under static section headings and dividers, with no collapsible\nsections or cascading settings menus. A heading is 24px of regular `text-tiny` in the\nforeground colour, a rule above every one but the first. A feature that switches on and off\n(Orbit, Floor) is the checkbox at its heading's right end, the title naming it; there is no\nseparate row for it. While the feature is off its section is that heading alone. Every\nsetting is one 24px row: its label in a 4rem muted column, its control filling the rest, so\na form's controls start on one line. A label is short under its heading (\"Colour\",\n\"Opacity\" under Floor); the control's accessible name stays whole (\"Floor opacity\"). A\ndropdown or colour is as wide as its value, at the row's right end. Sliders are shadcn's,\nas everywhere, with a committed value field after them. By part's palette is a row of\nswatches, then add and remove. Choices use Select controls. Continuous values use compact\nslider and committed-number fields; their keyboard input stays with the field. Colours\nretain swatches and a custom colour picker. Floor is on by default, at the model's Z=0\nplane. A long form scrolls within the available viewer height. A nested Select or colour\npicker owns Escape and outside interaction before its parent surface. The last ite\n\n<!-- [TRUNCATED AT 50KB] -->","isInternal":false,"tokens":12642,"sizeBytes":50161},{"name":"view-updates.md","path":"packages/ui/docs/view-updates.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/view-updates.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Responsive View updates\n\nView controls always edit the per-file `viewSettingsStore` immediately. That\nstore is the only settings authority. A mode is a preset batch; the renderer\nnever branches on its name. Rendering completion cannot rewrite controls,\npersistence, feature gates, or the Custom label.\n\n`kit/view-settings/viewUpdateCoordinator.js` owns one active operation and one replaceable\nrequested scene recipe. `kit/view-settings/useAppliedViewSettings.js` binds it to React.\nRapid edits replace pending work; they do not accumulate in a queue. Preparation\nis cancellable. Once scene reconciliation starts it finishes serially, but a\nsuperseded result is not presented. File changes/unmount cancel work and reject\noutstanding captures. Errors leave controls editable and expose Retry.\n\n## Cost policy\n\n`kit/view-settings/viewUpdatePlan.js` is the single classification point. Compare resource\nboundaries, never preset names:\n\n| Preparation | Live update |\n| --- | --- |\n| Lighting enable, quality, softbox size/fill | Exposure, rotation |\n| Surface style/color source, crossing opaque/transparent | Surface color, opacity within one transparency class |\n| Edge enable/visibility | Edge color |\n| Floor enable | Floor color, opacity, placement |\n| Clip enable/disable | Clip position, Reverse and Part colors after preparation |\n| Appearance shader change | Projection, background, grid, axes, Explode |\n\nBoth paths yield until the controls have painted, then apply the latest recipe.\nOnly the preparation path loads resources and compiles shader variants. Live\nupdates coalesce to the next frame and do not wait for an artificial debounce.\n\nThe Clip and Explode tool buttons add neutral control panels. Editing a slider or\nnumber enables the effect; X or pressing the tool again resets and removes it.\nClip uses original model bounds for its slider and the rendered plane, independent\nof pose and Explode. Its neutral endpoint is the maximum coordinate (minimum when\nflipped). It has one active axis and plane, exposed in its panel as a compact X/Y/Z\ndropdown and a single cut-amount slider. The slider starts neutral on the left and\nadvances from maximum to minimum coordinate; a flipped cut (`invert`) reverses that direction. CLI coordinates, including zero, remain supported. Clip and Explode stay outside mode presets.\n\n## Renderer and resources\n\n`kit/look/studioEnvironmentCache.js` keeps at most three reflection maps per\nviewport. `studioEnvironment.worker.js` generates PMREM in worker WebGL and\ntransfers half-float pixels; model geometry stays on the main renderer. Rotation\nand exposure reuse the map. Superseded work terminates the worker; completed\nmaps are reused by key. Workers terminate after transferring their pixels, so inactive tabs do not\nretain extra WebGL contexts. Disposal releases textures and any pending worker. Browsers\nwithout OffscreenCanvas/Worker use a synchronous compatibility path after the\ncontrols paint; the full responsiveness benefit requires worker WebGL.\n\n`kit/view-settings/viewUpdateGate.js` preserves the previous canvas framebuffer while the\nscene is reconciled and Three's `compileAsync` prepares its programs. The\nrenderer/context and cached geometry stay mounted. `viewportBuffer.js` queues\ncanvas resolution changes and flushes them inside the next actual draw callback.\nChanging DPR or dimensions outside that callback clears the retained frame, so\nno quality effect or resize observer may resize the backing buffer directly. A viewport\nresize DRAWS that frame instead, synchronously inside its ResizeObserver callback (after\nlayout, before paint): the canvas is sized 100% by CSS, and a frame requested from there\nwith `requestAnimationFrame` would land a frame late, painting the old picture stretched\nover the new box whenever the viewer changes width in one step (a window snap). That synchronous frame replaces any queued one, so a drag still draws once\nper frame, and an idle viewer draws nothing. While the gate holds, the draw is skipped\nlike any other, the buffer keeps its size, and the held picture waits for the frame that\nreleases it (`kit/viewport/ViewportResize.browser.test.mjs`). Presentation resumes after\npreparation; completion is acknowledged after an actual draw. Camera input is\naccepted during preparation, though the displayed pose can briefly wait while\nthe scene is held. Shader support and GPU drivers can still impose synchronous\nwork (notably shadow allocation/first draw); this is not a second renderer or a\npromise that all GPU work is preemptible.\n\nScreenshot/prompt capture awaits the coordinator's latest presented revision,\nso it cannot accidentally capture a half-applied recipe. Ordinary snapshots\noutside FileViewer retain their own scene setup lifecycle.\n\n## Status placement\n\n`kit/status/ViewUpdateStatus.jsx` only takes `status`, `onRetry`, and\n`className`. It has no scene/store knowledge. RendererShell draws it centred under the tool\nstrip and the tool in hand's sub-toolbar, or at the top of the viewport in the strip's row\nwhere there is no strip (preview, a load), without changing scheduling. It appears after 150ms with “Updating view…” (or\n“Preparing section view…” for Clip), stays nonmodal, and never disables controls.\nFast changes produce no spinner. Failures show a retry action.\n\nTests cover latest-request wins, cancellation back to the visible recipe,\nserialized reconciliation, failure/retry, capture readiness, disposal, and\nStrictMode/control independence. Real browser checks must also exercise cold\nRender, rapid preset/clip edits, a completed capture, and a warm return.\n","isInternal":false,"tokens":1138,"sizeBytes":5611},{"name":"viewer-host.md","path":"packages/ui/docs/viewer-host.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/docs/viewer-host.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Viewer host contract\n\n`FileViewer` requires one explicit `ViewerHost` from `@text-to-cad/ui/host`.\nShared UI implements rendering and document interaction; apps supply environmental\neffects. No shared feature discovers Electron, browser clipboard, a backend URL,\npersistent storage or page navigation. DOM, canvas, workers and layout remain\nshared. Missing optional methods mean an operation is unsupported.\n\nThe host contains `files`, optional native `fileActions`, `clipboard`,\n`promptContext`, the optional `promptFiles`, `navigation` (the optional `openFile(path)` and\n`home()`), the optional `links` (the app menu's), the optional `usage`\n(`used(feature)`: what the person used, for the host's telemetry to count, never what\nthey made; absent, nothing is counted) and `environment`. `environment` carries the resolved `colorScheme`,\nthe resolved `locale` (the language the view reads in: the person's choice, else what the\ncontext infers, as `@text-to-cad/ui/i18n`'s `useLanguage` resolves it; none, or one the viewer\ndoes not speak, reads English),\nthe keyboard `platform` (`darwin` shows ⌘, anything else Ctrl), the app's own\n`reducedMotion`, honoured beside the system's `prefers-reduced-motion`, and\n`compact`: a view drawn as a picture (the home draws its thumbnails this way, out\nof sight), where a CAD renderer draws the model alone — no tools, view actions,\nview cube or Quick Edit — and FileViewer draws no navbar. A view shown small in a\nconversation is not compact: it is the whole viewer, and its host hands it a way\nto full size as `fullSize` (`CadViewerProps.fullSize`), among the last of the navbar's\ncontrols and the home's, before the host's colour scheme control. CAD is a separate registration supplied with a\n`CadWorkspaceService`; the generic FileViewer does not import CAD. The HTTP CAD\nadapter can serve both apps, while desktop owns native runtime startup/recovery.\nSee [workspace resources](../../core/docs/workspace-resources.md) for resource\ntickets and cache identity.\n\nA host passes no tolerance to the STEP renderer: it draws each part at the display mesh its\npart's record names, which cadgen derived at its display tolerance\n([the display mesh](loading.md#1-the-display-mesh)).\n\nApps create services for the workspace lifetime. File tabs borrow them, while\nmounted renderers own scenes, document controllers and temporary resource leases.\nUnmounting one view releases its work without disposing another view's services\nor admitted cache writes. Hosts dispose a service when its workspace closes.\n\n## Interface map and implementations\n\nThe linked TypeScript definitions are the authoritative signatures. Import\ntheir compiled public package exports in application code; these source links\nare for reading and maintaining the contracts.\n\n| Contract | Definition | Public entry point |\n| --- | --- | --- |\n| `ViewerHost`, `ClipboardPort`, `PromptFiles` (a Quick Edit's views and annotations, saved as JSON), `usage` | [Host types](../src/host/types.ts) | `@text-to-cad/ui/host` |\n| `FileSource` (`stat`, and `list` and `search` where the host has them), `FileActions`, `FileViewerState` | [File viewer types](../src/file-viewer/types.ts) | `@text-to-cad/ui/file-viewer` |\n| `PromptContextPort` (`deliver`, `send`), bundles, references, delivery receipts and `formatPromptMessage` (the one message a Quick Edit is) | [Prompt types](../../core/src/prompt/types.ts) | `@text-to-cad/core/prompt` |\n| `CadWorkspaceService` (the catalog of the files on screen, `folder`, `search`), `createCadClient` (`{ origin, fetch, … }`), `isMissingFileError`, `CadResourceProvider`, worker tickets | [CAD service types](../../core/src/client/types.ts) | `@text-to-cad/core/client` |\n| `createHttpPromptFiles` (the viewer server's `PromptFiles`: `POST /__cad/prompt-files`, writes JSON and returns paths) | [Prompt files](../../core/src/client/promptFiles.js) | `@text-to-cad/core/client` |\n| `StepRendererOptions`, `CadLiveBinding` | [STEP registration](../src/renderers/step/index.ts) | `@text-to-cad/ui/renderers/step` |\n| `TabStore`, `TabRecordStorage`, `createTabStore`, `useTabViewerState` (the tab's one store: its settings, the view of the file on screen, and `FileViewer`'s state from both) | [Tab store](../src/tab-store/tabStore.ts), [the record](../src/tab-store/tabRecord.ts) | `@text-to-cad/ui/tab-store` |\n| `CadPreferenceSource`, `createCadPreferences` (the tab's settings as renderers read them) | [Viewer preferences](../src/renderers/workspace/preferences.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `DxfRendererOptions` (2D drawings; declines every camera, display and selection command) | [DXF registration](../src/renderers/dxf/index.ts) | `@text-to-cad/ui/renderers/dxf` |\n| `GlbRendererOptions`, `LiveViewBinding`, `LiveViewController` | [GLB registration](../src/renderers/glb/index.ts), [live binding](../src/renderers/kit/shell/liveBinding.ts) | `@text-to-cad/ui/renderers/glb` |\n| `MeshRendererOptions` (STL, 3MF), `LiveViewBinding`, `LiveViewController` | [Mesh registration](../src/renderers/mesh/index.ts) | `@text-to-cad/ui/renderers/mesh` |\n| `RobotRendererOptions` (URDF, SRDF, SDF), `RobotLiveController`, `RobotLiveState` (`selectedLinks`, `selectedPartIds`) | [Robot registration](../src/renderers/robot/index.ts) | `@text-to-cad/ui/renderers/robot` |\n| `ViewerCommands`, `ViewerCommandSource` (the host requests every viewer renderer takes) | [Viewer commands](../src/renderers/workspace/commands.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `createLiveRegistry`, `LiveRegistry` (the host's handle on the mounted view: its renderers' `live`) | [Live registry](../src/host/liveRegistry.ts) | `@text-to-cad/ui/host` |\n| `ModelLibrary`, `ModelLibrarySource`, `useModelThumbnail` (a host's home, under the navbar: the CAD title and byline over the host's links (GitHub, Discord, X), then the models opened before, pinned first, as cards or rows, and with none, where the host has a chooser, an \"Open File\" card that opens it; placeholder cards or rows while the list is read (again every two seconds while the home is up, so a model rebuilt meanwhile is pictured again), and a spinner over the model being opened, which takes no second press until `open` settles; `pick` only where the host has a file chooser) | [Model library](../src/library/ModelLibrary.tsx), [thumbnails](../src/library/thumbnails.ts) | `@text-to-cad/ui/library` |\n| `CadViewer` (FileViewer over a CAD client, one file by absolute path or the home: the five CAD renderers, catalog following, the home, the \"File does not exist\" and \"Could not open that file\" pages, the library's pictures) | [CAD viewer](../src/cad-viewer/CadViewer.tsx) | `@text-to-cad/ui/cad-viewer` |\n| `createCadFileSource` (a CAD client as a read-only `FileSource`: `stat` through the client's `resolveEntry`, `list` through `folder`, `search` through `search`), `createCadFileActions` (the file menu's Copy path, the absolute path, and Reveal), `normalizePath`, `baseName`, `joinPath`, `contentRevision` | [Catalog](../src/cad-viewer/catalog.ts) | `@text-to-cad/ui/catalog` |\n| `ViewerLinks`, `viewerLinks` (the version, GitHub, Discord, where a new issue opens, the newest release and how to update, and how a link is followed), `issueUrl` (a new issue filled in — its title, labels and body — its address kept under `ISSUE_URL_MAX`) | [Host types](../src/host/types.ts), [links](../src/file-viewer/navigation/links.js) | `@text-to-cad/ui/links` |\n| `buildId` (for an app's Vite config: which build this is, \"\" for the release's own, whose environment names its version in `TEXT_TO_CAD_RELEASE`, else the checkout's commit) | [Build id](../scripts/build-id.mjs) | `@text-to-cad/ui/build-id` |\n\nStart with the actual compositions: [web App](../../../apps/web/src/App.tsx) and the\nMCP app's [ModelView](../../../apps/mcp/src/ModelView.tsx), both one `CadViewer`.\nIts imports lead to the app-owned `host/`, `adapters/` and persistence\nimplementations. [Web storage](../../../apps/web/docs/storage.md) documents\nbrowser lifetimes. Shared component tests can\nuse the [explicit fake host](../src/host/testing/host.ts).\n\n## A CAD renderer's controls\n\nThe controls of a CAD renderer (STEP, GLB, mesh, robot) are its own, over the viewport: its\ntool strip, the tool in hand's sub-toolbar and its file sidebar, its view's controls (Display,\nPreview) drawn into the navbar's `navbarSlot` (`settings-ui.md#the-file-sidebar-and-the-tool-stack`); a\nDXF's are its own canvas's. Nothing a renderer does opens or closes the explorer (only the\nfile's name opens that). The layout a person gives the file sidebar — each section's size —\nand the tool stack's panels is one\nof the tab's settings (`settings.toolStack` of the tab record, `@text-to-cad/ui/tab-store`),\nbeside the appearance and the home's layout.\n\n## Preview and renderer navigation actions\n\nPreview is the shared shell's own state (`previewing`); there is no host\nprop for it and a host cannot start or observe it. It is a 3D view's alone, and each\nrenderer says whether its view is one: it declares `previewable` to the shell\n(`useRendererShell`), as STEP, GLB, STL/3MF and URDF/SRDF/SDF do. A view that does\nnot — a DXF's 2D drawing, which draws its own surface without the shell — has no\nPreview at all, no control and not a disabled one, and nothing that asks for it\n(a link or a host request, should either come to) takes it there: it keeps\nthe normal view. The CAD app's Full size, inline, is the host's display mode, not\nPreview: it shows the normal view, of any file. Preview keeps the navbar, so the app\nmenu and the explorer still lead to other files; the host is not told it is on.\nPreview and the tools view are two states: entering leaves the tools view's state as it\nis (the pose, the routine, the selection, hidden and isolated parts, Explode and Clip,\nmeasurements, the camera, the tool and its panels) and Preview starts from the model as\nauthored, with its own camera, still, and its routine at rest, drawn in Render whatever the\nfile's display mode (the tools view never draws Render); leaving throws Preview's state\naway and gives the tools view back exactly as it was, its routine included, in the file's\nmode. Preview is a view of its own, mounted over the tools view's while it is open\n(`kit/shell/PreviewView.jsx`), with its own scene, camera, pose and routine, sharing only the\npage's GPU device; the tools view's viewport stays mounted under it and nothing of it changes.\nNothing of Preview is persisted. Routines play from the file sidebar's Animation section in either\nview. Preview opens still: Orbit is off every time it opens, and its settings panel turns it\non. The file's view's `playback` — Orbit's speed, Autoplay, and the chosen speed and loop —\nis kept between visits to Preview and across a reload, and a file's routine plays on entry\nonly when its Autoplay is on. The rules are in\n[settings-ui.md](settings-ui.md#camera-animation-and-preview).\n\nA renderer can publish `FileNavigationAction[]` through\n`RendererViewProps.onNavigationActionsChange`. The CAD renderers publish one, and only\nwhile a person has put away an alert card the model survives: the card's own icon in its\ncolour, named after the alert, which brings the card back (`useAlertDismissal`,\n`kit/status/ViewerAlertCard.jsx`). The\nshared navbar shows these at its right, after the host's update button and before the\nhost's Full size and colour scheme control. Each action declares its icon, accessible label, an\noptional shorter hover `hint`, disabled state and invocation callback. Registration belongs to the mounted\nfile generation: publish an empty list on cleanup; departing renderers cannot\nreplace a new file's actions. Publish only when action metadata changes; stable\ncommands should read the current viewport through a ref, avoiding parent/child\nrender loops. These actions use existing host capabilities for effects. What a\nperson tells the agent about a CAD file is [Quick Edit](#prompt-handoff)'s.\n\nA renderer's controls for the view are the renderer's own, drawn into the box FileViewer\nhands it at the navbar's right end (`navbarSlot`): the CAD viewer's for a 3D view, Display and\nPreview, and in Preview its Preview settings and Exit preview; the navbar stays over it.\nWhere there is no navbar (a view shown small) the renderer draws them over its view. The host's `captureRequest`\ncommand is the same capture: to a composer destination it delivers the view and\nthe selection through `promptContext.deliver`. Neither route detects the platform.\n\n`navigation.openFile(path)` shows a file, by its absolute path, in this view: a pick\nin the explorer, a renderer's link. `navigation.home()`, where the host has a home,\nshows it in this view: from a file, the app menu's Back to files (the menu the navbar's\nlogo opens) leads to it; over the home the same navbar has the logo and its menu, with no\nBack to files. `CadViewer` implements both\nover the host's `onShow(path)` (`''` is the home), and gives a host a home only with a\n`library`: a host whose own navigation shows one file (Codex's file handler) passes none,\nand its view shows that file and nothing else — no home, its menu no Back to files, no\nexplorer, and no `openFile`, so a renderer's link to another file (a robot's mesh) is plain\ntext there. A host that keeps a view's own history,\nwhere no browser does (the CAD app's views), hands it as `history` (`ViewerHistory`:\n`canGoBack`, `canGoForward`, `back()`, `forward()`; `CadViewerProps.history`): the navbar\ndraws Back and Forward over it, over a file and over the home alike. What the history holds is\nthe host's (the CAD app's: every file the view showed and the home, a step like a file, the\nagent's shows and the person's moves alike); the web hands none, its page having the\nbrowser's, where the home is a step too. `onShown(path | null)` says which file the catalog\nhas on screen (null for the home, or a file still resolving or missing), for a host that\nnames its page after it or records it. The host decides what showing a file means,\nbecause only the host knows which view shows it: the web viewer is one page per machine\nand writes the file into its URL (`?file=`, a new history entry, so the browser's back\nand forward move between files, and a bare URL is the home), and the CAD app, which\nrenders in Codex (the sidebar, a tab beside each thread and a file handler) and inline in\nClaude Desktop and other MCP Apps hosts, shows it in the view that asked or, from the\nhome, launches it.\n\n## Adding a shared feature\n\n1. Implement reusable interaction and presentation in UI, with cross-consumer\n   non-React domain behavior in core. Renderer-private helpers stay with their\n   renderer in UI. Keep project, session and operating-system workflows in apps.\n2. Reuse an existing injected contract. If a new environmental effect is needed,\n   extend its narrow consumer-owned interface and implement it in each app, or\n   explicitly advertise that the host cannot perform it. Shared UI must not\n   fall back to browser globals or branch on `isWeb`/`isDesktop`.\n3. Use subscribed capability/destination state for availability and labels:\n   Quick Edit offers only what the destination and the prompt port can do. Use\n   a named additive slot for an extra app-enabled interface; the shared\n   renderer never imports the app's component or stores.\n4. Define identity, lifetime, cancellation and result semantics with the\n   contract. Publish serializable view state through the controlled binding;\n   the app chooses its storage. Keep workspace services stable across tab mounts.\n5. Update this contract or its linked domain guide, add focused shared/adapter\n   coverage and verify affected host integrations. Exercise source changes and\n   late results for asynchronous effects, and warm reuse for resource changes.\n   Run `npm run check:boundaries` and rebuild compiled packages before app checks.\n\nPlatform-agnostic UI can use DOM, canvas, React and renderer-owned workers.\nFilesystem access, transport selection, credentials, clipboard, persistent\nstorage, page navigation and native process lifecycle remain host responsibilities.\nSo do a transport's limits: a host whose channel caps one reply declares the\nmost one batched read may ask for (`createCadClient({ maxBatchBytes })`), and the\nshared loader never asks for more; the host carries a longer body in parts (the\nCAD app's tunnel reads it a range at a time), and the loader sees it whole.\n\n## Prompt handoff\n\nContext actions use `PromptContextPort.deliver(context)`. Desktop\ninserts into a compatible draft; web prepares clipboard representations. Ordinary\nexplicit copy/paste controls use the separate `ClipboardPort`. Delivery never\nsubmits a prompt. `send(context)`, present only where the host has a chat to post\nto, posts the context as the person's message now (`sent`); absent, nothing can\nsend one.\n\nThe portable types and validators live at `@text-to-cad/core/prompt`. One versioned\nbundle contains ordered text, reference and attachment parts with unique IDs.\nAn attachment has a MIME type, name and Blob or Promise of Blob. Its optional\n`about` array names reference-part IDs, so a screenshot and several selections\ncan travel together, its optional `label` (\"Annotation\") says what it is for a\nmessage that names it by path, and its optional `path` (a path, or the promise of\none) is where it is already saved: `resolveAttachmentPaths(context)` waits for\nthem all. Producers freeze resource and selection identity before\nasynchronous capture. Blob URLs are delivery leases, never portable identity.\n\nA reference contains a workspace-file identity (the file's absolute path,\n`/`-separated: `/a/b.step`, `C:/a/b.step`; validation rejects any other spelling)\nor HTTP(S) URL plus a tagged\nselection: `whole-resource`, `text-range`, or `cad-selector`. Text positions are\nzero-based UTF-16 with an exclusive end. CAD selectors use core's validated\ncadgen grammar, not STEP entity numbers. Preserve a revision when available;\nreferences do not promise to survive edits. Shared serialization handles quoting.\n\n`formatPromptMessage(context, { attachmentPath })` is the one\nmessage a Quick Edit is, whether sent, queued or copied: what the person wrote;\nthen `File: <path>`; then `References:` and one reference per line; then each\nJSON file by its label and path (`Views (cadgen snapshot --job): <job.json>`,\n`Annotation: <file.annotation.json>`), where `attachmentPath` says where it was saved.\nNo pictures are rendered, attached or sent.\n\n\n`PromptContextAction` (a host renderer's prompt action) reads the subscribed\ndestination and labels the action Add to prompt or Copy for prompt. It calls delivery during the\nuser gesture, before awaiting capture: browser activation and desktop destination\nbinding depend on this. Availability and advertised attachment/combination limits\nbelong to the host. Every result is acknowledged: added, copied, sent, partial,\ndeferred, cancelled or failed. `partIds` describes accepted/written parts, not a\nclaim that a different application pasted them.\n\nDesktop captures the compatible draft destination before awaiting attachments,\nvalidates the complete bundle and rechecks that destination before atomic draft\nacceptance. Switching chats cannot redirect an in-flight capture. Existing text\nand attachments survive; operation IDs prevent duplicate acceptance. Workspace\nmismatch uses the app's explicit Start chat here recovery. Invalid attachments\nleave the draft unchanged. Direct capture failure never shows success.\n\nWeb supports text/reference serialization and one PNG. A combined clipboard\nwrite reports that text and PNG are separate representations; some receivers\npaste only one. Unsupported attachment types or combinations fail explicitly.\nHosts must resolve/validate accepted attachments and consume failed encoders;\nthey do not transfer Promise or Blob values across native IPC.\n\nQuick Edit is the shared, host-neutral note to the agent\n([the design system](settings-ui.md#quick-edit)), and its buttons are what the host can carry out.\nEvery way it goes carries its words, references and a snapshot packet captured at\nCopy, Queue or Send. The packet includes the current view plus distinct drawn views\nwith remaining ink; an equivalent drawn entry uses the exact current framing.\nShared input and inline annotation ([annotation](annotation.md)) appear once; measurements\nare the person's and never go. Each job may override shared fields. Projection and lens are preserved.\nPackets up to 1024 bytes of compact UTF-8 JSON are inlined; larger packets are\nwritten as one temporary `.snapshot.json` and named by path. Hosts use\n`resolvePromptMessage` to resolve an attachment's optional `inlineText` before its\npath, keeping destination binding inside the gesture. Drawing, selecting and typing\nkeep data in memory. Nothing renders at the press. The agent saves inline JSON to\na temporary file before calling `cadgen snapshot --job <file>`.\n**Copy Prompt** is always there: the message goes through\n`ClipboardPort.writeText`, which takes a `Promise<string>` so the write starts\ninside the gesture while the files are still being made; its references name their\nfiles by absolute path, as copied references do (below), and every file is named by\nits path, since text carries no file (without `promptFiles`, a prompt carries none). **Queue** is there where the\ndestination is a composer (`destination.kind === \"composer\"`): `deliver`, the\ncontext for the person's next message. **Send** is there where the prompt port\nhas `send`. The context is built at the press, from what is live then; a changed\ndocument or revision never retargets it. A Quick Edit that reached its destination\n(copied, queued or sent: no `failed` or `cancelled` receipt) tells the host once,\n`usage.used('quickEdit')`, for its count; a host that throws there changes nothing.\n\n`ViewerHost.promptFiles` is a `PromptFiles`: `create({ name, input, views, annotation })`\nanswers `{ inline: compactJson }` or `{ snapshot: absolutePath }`, `read(path)` fetches JSON,\nand optional `save({ name, kind, data })` remains for older clients that export individual\nannotation files. Current chips do not call it. `createHttpPromptFiles({ origin, fetch })` from\n`@text-to-cad/core/client` posts to `POST /__cad/prompt-files`, which validates the packet in memory and returns inline JSON or writes the larger\nsnapshot and returns its path with HTTP 200. The client advertises `acceptInline: true`;\nrequests without it always receive a saved path, so already-open clients remain compatible. There is no render worker\nor polling endpoint. Guarded `GET /__cad/prompt-files/<name>` serves JSON only;\n`POST /__cad/prompt-files/save` supports those older clients. The files\nare scratch in the system's temporary directory and outlive the viewer until the\nsystem clears them. The response and persisted snapshot name input and output\nfiles by absolute path; annotation data is inline, so replay works from another\nworking folder. Both apps pass one; the MCP app's\nreaches the server through its tunnel.\n\n## App-specific interfaces\n\nThe optional CAD `live` binding receives a `CadLiveController` only while its\nviewport is mounted. `readState()` returns a detached serializable snapshot of\nthe actual resource/revision, current selections, camera, display and render\nmode. It reads the viewport camera directly, with its last camera snapshot as\nan unmount fallback. During a rebuild that retains a predecessor mesh, its\ndisplayed document revision remains in the snapshot until replacement. Apps\nown the binding registry and any IPC/tool transport;\nshared UI does not infer view state from a backend catalog or stored tab state.\n\nThe controller selects available selectors, clears selection, applies a camera,\nresets framing, applies grouped View settings, selects presets and captures a PNG.\n`thumbnail({ width, height })` is a library card's picture: it waits for the view\nto settle — the renderer's own live state saying the whole file is loaded and\ndrawn, never a timer — then draws the model framed whole from the default\ndirection at the card's aspect, on its own (no floor, grid or axes, whatever the\nperson turned on) and on transparency, so it suits either scheme, off to the side of the view, so\nthe person's camera, panels and window never show in it and nothing on screen\nchanges (`kit/viewport/thumbnail.js`; a DXF paints its fitted drawing on a canvas\nof its own). A view that goes before it settles rejects it. `useModelThumbnail`\nkeeps one per revision of a file per mounted view (a model rebuilt while it is\nopen is pictured again once its new revision is drawn), and only for the file the\nview still shows.\nOn the home, a card on screen with no picture, or one taken before its file last\nchanged (`pictured` against `modified`), is pictured out of sight wherever the host\nkeeps pictures (`onThumbnail`): `CadViewer` mounts a viewer of its own for it, over\nits own client — its own renderers and live binding, compact, behind the page at the\npicture's size — one model at a time, each once per visit, and only a model whose\nartifact status is already `compiled`, and keeps the picture through `onThumbnail`\nas it keeps the file on screen's. The home never builds anything: an unbuilt model\nkeeps its placeholder until a view shows it.\n`readState().display` and `setDisplaySettings(patch)` use the same sparse display\nschema as snapshots: `mode` selects `solid`, `shaded`, `parts`, `xray`, `hidden-line`,\nor `wireframe` (each a look at its own defaults), `grid` switches the measuring grid,\nand appearance, camera, clip and exploded are shared. Patches merge supplied fields;\na mode has no settings of its own, and a patch naming any is refused. There is no\nCustom state or legacy shape migration. Render is no mode: it is Preview's, which no\nlive command opens, so `mode: \"render\"` is refused as any unknown mode is; Preview's\nown settings are the viewer's panel's, not the display's. Camera projection/lens changes preserve viewport pose and zoom.\n`resetCamera` frames the model again without turning the camera — exactly what\nSTEP's context-menu \"Zoom to fit\" does. Cube shortcuts change only orientation.\n\nA file's view holds its camera: a mount restores it in place of the fit, and fits\nwhen there is none. Live\ncommands reject retired field names rather than maintaining a second display\nauthority. Unavailable selectors fail explicitly; topology is not silently\nloaded. Mutations return a view snapshot after the React frame; a mode switch\nwaits for the requested settings to commit, with a ten-second bound.\nLoading views\nreject commands, and an operation whose resource/revision changes or viewport\nunmounts before completion rejects its late result. On unmount, the controller\nreleases its component closure and retains only its final snapshot with\n`active:false`; every subsequent command requires showing the model tab first.\nHosts may cache that inactive snapshot without retaining a scene or moving focus.\n\n## Files, state and shutdown\n\n`FileSource` reads files; `FileActions` holds the file menu's native operations\n(Copy path, Reveal), and the viewer never writes a file. Both apps read any CAD\nfile on the machine by its absolute path, through the viewer server: what a view\nlists and searches is the server's, and a request for a file that is not there is a\n`cad-file-missing` error (`isMissingFileError`).\n\nFileViewer opens a file's document when the file is shown, and again only on `reload`: a\nrenderer follows its file itself. A CAD renderer (its document from\n`prepareWorkspaceEntry`) reads the live catalog entry, so a content change — or a write\nthat briefly empties the file — loads the next revision behind the model on screen, under\n\"Updating model…\" (a drawing's: \"Updating drawing…\"). Once a model has been shown, a\nrebuild is an update, never the loading screen.\n\nState remains controlled through FileViewer props, and everything the viewer keeps\nis the tab's (`@text-to-cad/ui/tab-store`): one record per tab, `{ version, settings,\nfiles }` (`version` is `TAB_RECORD_VERSION`, 2; a record another version wrote reads as\nthe defaults), thrown out with the tab and kept across a reload. The host supplies where it\nlives through one adapter, `TabRecordStorage` — a synchronous read and write of the\nwhole record: the web over `sessionStorage`, the CAD app over memory\n(`memoryTabRecord`) — and the package owns the record's shape, version and normalization. `settings` is\n`{ toolStack, appearance, library }`: tab-wide (the file sidebar's and tool stack's layout, the\nappearance, the home's layout), and what every renderer reads as its preferences;\n`files` holds the view of the file on screen, under `[absolute path, renderer id]`, and no\nother: a write keeps the newest alone (`TAB_FILE_LIMIT`), and `CadViewer` drops the view\nof a file it leaves — for another file or for the home — once the\ndeparting renderer's last write has landed (`files.retain(path | null)`; `null`, the home,\ndrops every view). The store reads and writes a view (`files.read`, `write`, `remove`),\nlists them all (`all()`) and merges a view's changes (`merge`). A reload of the tab shows\nthe same file, so it brings that view back; a file opened again after leaving it starts\nat the defaults. In the CAD app there is one record per view: a view the host\ncreates again (its frame re-created) starts afresh, since nothing names a view across its\nframes. A view is `{ camera, display, playback, renderer }` (`kit/shell/fileView.js`):\nthe camera is restored in place of the open-time fit, the display settings with their\nClip and Explode, the playback settings (Orbit's speed, Autoplay, and the chosen speed and\nloop), and the renderer's own slices each behind the\nsignature it was written against — a slice that no longer fits the file on screen is\ndropped, the camera, the display and the playback never. Not in it, and started afresh\non every open: the tool in hand, the selection, measurements, ink, Preview, a\nroutine's time and Quick Edit's note. Apps merge\nwhat a view changed into the store (`files.merge`); a stale view must not overwrite\nanother view's entries. Material appearance is source-owned and read-only. Live\nselection and scene ownership belong to the mounted view.\n\nA mounted view writes its view shortly after each change (the camera on every move,\ndebounced) and once more when it unmounts, and nothing after that; the store writes\nthrough synchronously, so what the tab last saw is what a reload restores. Nothing\nsaves document content or promises an asynchronous operation will finish during page\nexit. Web owns pagehide\n(which unmounts the app), focus, visibility, history and reload under a new server. Desktop\nowns window/runtime lifecycle and IPC.\n\nThe dependency checker enforces host boundaries, including worker source. The\nbrowser harness mounts real renderers with explicit fake hosts; app tests cover\nnative/clipboard delivery and multiple-view state merges. Warm-cache regression\ntests remain required for resource changes.\n\n## Keyboard scope\n\nViewer shortcuts consume only events from their own viewer, or from the page\nbackground after a pointer press in it, so another viewer or the composer never\nreceives Escape, copy or orbit keys on its behalf\n([settings-ui.md](settings-ui.md#keyboard)). Annotate's surface reads the host's\nkeyboard `platform` (`environment.platform`) for its undo and redo keys.\n\n## Host chrome slots\n\nFileViewer draws ONE navbar, the same in every app, over every file and over a host's home.\nOver the home it has only the logo and its menu (no Back to files), Back and Forward where\nthe host hands a `history`, and the host's update and Full size, each where it stands over\na file; the home's search, layout switch and Open stay\nin its Recent Files row, and its links under its title. Left: the text-to-cad \"C\" logo,\nwhich opens the app menu (`AppMenu`, `file-viewer/navigation/AppMenu.jsx`), then Back and\nForward where the host hands a `history`, then the open file's name and its ⋯ menu. The\nlogo, Back and Forward, the name and the ⋯ share one style (`NAV_ITEM_CLASS`):\ntransparent at rest, the same accent background while the pointer is on them or their menu\nis open, a pointer cursor, and no tooltip. The name opens the explorer where the host's\nfiles can be browsed (`files.list` and `files.search`), and is plain text where they\ncannot; the ⋯ offers Copy path and Reveal in Finder / Show in Explorer / Show in file\nmanager, each where `fileActions` can do it, and is absent where it can do none (no\nright-click on the name). Right, only what a host or a file adds: the host's update button\nfirst (`update`, while its install is behind), the renderer's navigation actions, the\nhost's Full size where it shows the view small\n(`fullSize`, the inline card's way to full size, over the home too), and last the box the\nrenderer draws its own controls into (over a file only). The navbar holds no settings.\n\nThe app menu is FileViewer's, built from the host's `navigation.home`, `links` and\n`environment.platform` and the person's `FileViewerProps.appSettings` (which `CadViewer`\npasses on), the same over a file and over the home. It is a dropdown on the floating surface.\nFirst **Back to files**, where the host has a home; then\nthe host's on/off settings (`appSettings`, below) as checkbox items, checked at the right,\nwhich a press turns without closing the menu; then, where the host has a tracker\n(`links.issues`), **Send feedback**, a link to a new issue titled \"Feedback: \"\n(`feedbackUrl`, `NavbarLinks.jsx`), for the person to finish, naming the version and\n`environment.platform` — it has no label: the project has none for feedback, and what is\nsaid may be a bug, a request or a question — then **GitHub** and **Discord**; and last, in\ngray, \"v<version> · Made by @<handle>\": the version links its release notes\n(`links.release`) and `MadeBy` the host's X account. The version is the release's own build's\nas it is (`v0.7.15`), and any other build's with its id (`v0.7.15-dev.b80844940`, the commit\nit was built from, `-dirty` with uncommitted changes): `viewerLinks({ version, build })`,\nwhere each app's Vite config names the build through `@text-to-cad/ui/build-id`, and a new\nissue names it the same way. The home also has GitHub, which says the project is open\nsource, Discord and X as icon links under its wordmark (`HomeLinks`), in that order.\n\nAn alert card's Report Issue opens a new issue too,\ntitled \"Issue: \" and labelled `bug`, filled in from the card (`kit/status/reportIssue.js`): its\ntitle, message and failure, the file's name, the version and platform, then its Details, cut from\ntheir end to keep the address, title and labels included, under `ISSUE_URL_MAX`. No path of the\nmachine goes with it: the file's path is its name wherever the card writes it, and a home\ndirectory in anything else is `~/`. A label is a suggestion: GitHub applies a URL's labels only\nfor someone with triage access to the repository and drops them for everyone else. A link opens\nthe ordinary way unless the host supplies\n`links.open` (a page in a sandboxed frame hands it to its host), which every link of the\nmenu follows. The colour scheme is the menu's Theme row (`colorScheme`, a `CadViewer`\nprop), over a file and on the home alike; no host has a light/dark button of its own, and\nDisplay holds no appearance control. `language` (`{ locale, inferred, choice, onChoiceChange }`, a `CadViewer` prop) is the person's\nlanguage for the app menu's Language row: `choice` null is Automatic, `inferred` what Automatic\nmeans; both apps keep the choice in the person's settings (`/__cad/language`, the `language`\nsection of `settings.json`), so it holds for every CAD view. `appSettings` (`{ id, section, label, checked, disabled?, onCheckedChange }[]`,\na `CadViewer` prop) are the host's own on/off settings: checkbox items of the app menu, in the\norder they come (Share anonymous usage data, in both apps), the same in the\nlogo's menu and the home's; nothing of them reaches a renderer, and Display holds none. The\nhost owns what each one does and where it is kept.\n`@text-to-cad/ui/consent` is the usage stats toggle both apps share:\n`useAnalyticsConsent(consent)`, which turns the host's consent call into its `appSettings` row\n(Share anonymous usage data). Nothing asks: cadgen's telemetry is on by default once a `cadgen` command\nhas said so. The host supplies the call and where the answer is kept.\n`@text-to-cad/ui/update` is the update button both apps share, from cadgen's version check:\n`UpdateButton`, the blue button a host hands the viewer as its `update` (`CadViewerProps.update`,\nfirst among the navbar's right-hand controls, an icon, over every file and over the home), which opens its card in a popover, and `useUpdateNotice(call, initial)`, which\nstarts on the notice the host already has (a CAD app's launch carries it; the web page reads it with\nits server's description), so the button draws with the page, and reads the host's call again when\nthe page regains focus. Nothing it does is an answer the server keeps: the button stays while the\ninstall is behind and goes once the update lands. The card sends its prompt\nto the agent's chat through the host's `send`, where the host can, and otherwise copies it through\n`copy`, its button then Copy prompt; the prompt has a copy icon in its top-right corner either way; the host decides which it supplies. Its\nManual installation link, the notice's `instructions`, opens through the host's `onLink`. The shell\nhandles placement and hides the toolbar in\nPreview; the host owns callbacks and preferences. None of these imply platform\ndetection or move application-specific release/network behavior into shared UI.\n\nThe explorer is a popover under the file's name (`FolderExplorer.jsx`;\n[FileViewer](file-viewer.md#the-navbar-and-the-explorer)): it floats over the view and\nnever resizes it.\n\nA file that will not open leaves the navbar over a page of the host's\n(`presentation.error`). `CadViewer`'s says \"File does not exist\" and the file's path\nwhen it is missing (`isMissingFileError`), and \"Could not open that file\" and the\nreason otherwise, each with a Go home button where the host has a home.\n\nThe viewport's edges and corners are the shell's, never the host's: the tool strip at the\ntop centre, ending in the view's own controls (Display and Preview, a 3D view's), the tool in\nhand's sub-toolbar under it, the file sidebar down the left edge, Quick Edit at the\ntop-right, and the view cube at the bottom-right. A host's one notice goes through the shell too: `notice` is drawn at the top-right once the\nfile is on screen, never while it loads or after it failed to, with Quick Edit stacked under\nit while it stands; the home never shows it. Neither app has one now.\nWhat Quick Edit offers follows the subscribed destination capability and\nthe ports, never an app name: Copy Prompt always, Queue for a composer\ndestination, Send where the prompt port has `send`. Native clipboard effects\nremain in the host implementation.\n\nA STEP's selection copy (a chip's click, the copy shortcut) and Annotate's\ncopy shortcut (the view with its annotations) use `ClipboardPort` directly, including on\ndesktop: the references as text, the view with its ink as a PNG through\n`writeImage`. Their copy shortcut follows the same path. A double-click copies\nnothing: on a face or edge it turns the view square to it, on a component or\nsubassembly it isolates it. The host supplies\n`environment.platform` for the ⌘C / Ctrl+C hint; the web host derives that field\nfrom its browser environment.\n\nAn alert card's Details have a copy icon in their box's top-right corner\n(`kit/status/ViewerAlertCard.jsx`): it copies the whole of them, however far they scroll,\nthrough `ClipboardPort.writeText`, and shows a tick for a moment. A host whose clipboard\nrefuses the write gets a line saying so under the box, where the text stays to select; a\nview with no host clipboard gets no icon. Each host makes its port copy wherever it can: the\nweb falls back to the page's copy command when the browser refuses the asynchronous\nclipboard, and so does the CAD app when its host grants its frame no clipboard.\n\nA copied reference names its file by its absolute path (`<path>#<selector>`), and so\ndoes a copied Quick Edit: every view spells a file the same way, so a reference pasted\ninto a chat or a tool says exactly which file it means.\n\nNothing of the viewer sits at the bottom centre. A host whose own control floats over\nthat edge (a chat's composer) sets `--cad-host-bottom-inset` on an ancestor to the height it\ncovers: the model library scrolls its last rows clear of it. A host whose own control\nfloats over the view's bottom-right corner sets `--cad-viewport-corner-bottom` to the room\nit takes from the bottom edge, and the view cube sits that much higher. To choose the\nopposite corner instead, set `--cad-viewport-corner-left: 2px` and\n`--cad-viewport-corner-right: auto`; their defaults are `auto` and the cube's right inset.\nCodex's tab presentation uses the left corner across sidebar, thread and file views.\nThe shared viewer consumes these layout values without identifying the host.\n\nA renderer's update status is its own: the CAD renderers show it centred under the tool\nstrip and its sub-toolbar. The navbar carries none.\n","isInternal":false,"tokens":9680,"sizeBytes":41378},{"name":"openai.yaml","path":"skills/bambu-labs/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/bambu-labs/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"Bambu Labs\"\n  short_description: \"Send prints to Bambu printers via Bambu Connect\"\n  default_prompt: \"Use $bambu-labs to send my sliced print to my Bambu Lab printer through Bambu Connect.\"\n","isInternal":false,"tokens":60,"sizeBytes":218},{"name":"SKILL.md","path":"skills/bambu-labs/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/bambu-labs/SKILL.md","title":"bambu-labs","category":"anthropic-skill","format":"markdown","content":"---\nname: bambu-labs\ndescription: Send prints to Bambu Lab printers through Bambu Connect, Bambu Lab's official app for printing from other software, or Bambu Studio. Use when the user wants to print a sliced `.gcode.3mf`, a Bambu `.gcode` or an unsliced model on a Bambu Lab printer, over Bambu Cloud or LAN. The agent opens the file in the app; the user picks the printer and starts the print there.\nlicense: MIT\n---\n\n# Bambu Labs\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nBambu Lab printers take print jobs from other software only through Bambu\nConnect, Bambu Lab's desktop app for third-party tools\n(https://wiki.bambulab.com/en/software/third-party-integration). This skill\nopens a file in Bambu Connect, or an unsliced model in Bambu Studio; the user\npicks the printer and starts the print there. The agent never controls a\nprinter and never asks for access codes.\n\n## Requirements\n\nBambu Connect (Windows 10 or later, macOS 13 or later), signed in to the user's\nBambu Lab account, with the printer on that account or reachable in LAN mode\n(https://wiki.bambulab.com/en/software/bambu-connect). On Linux, where Bambu\nConnect is still in development, use Bambu Studio.\n\n## Choose the handoff\n\n| What the user has | Handoff |\n| --- | --- |\n| A sliced `.gcode.3mf`, from `$gcode` or Bambu Studio's or OrcaSlicer's \"Export plate sliced file\" | Open it in Bambu Connect |\n| A plain `.gcode` sliced with this printer's Bambu profile | Open it in Bambu Connect; if Bambu Connect refuses it, slice the model in Bambu Studio instead |\n| A model with no slice: `.3mf`, `.stl` or `.step` | Open it in Bambu Studio, which slices it and sends it to the printer itself |\n\n## Open a file in Bambu Connect\n\nBambu Connect imports a file from a `bambu-connect://import-file` link with\nthree parameters: `path`, the file's absolute path, and `name`, the name to\nshow, each percent-encoded the way JavaScript's `encodeURIComponent` does it\n(Python: `urllib.parse.quote(value, safe=\"\")`); and `version=1.0.0`. For\n`/Users/me/prints/bracket.gcode.3mf`:\n\n```text\nbambu-connect://import-file?path=%2FUsers%2Fme%2Fprints%2Fbracket.gcode.3mf&name=bracket&version=1.0.0\n```\n\nOpen the link with `open \"<link>\"` on macOS, or `Start-Process \"<link>\"` in\nPowerShell on Windows. Bambu Connect comes forward with the file loaded.\n\n## Open a model in Bambu Studio\n\nOn macOS run `open -a BambuStudio <file>` (some installs name the app\n`Bambu Studio`). On Windows open the file from Bambu Studio's File menu, or run\n`Start-Process <file>` when Bambu Studio is the file's default app. On Linux run\n`bambu-studio <file>`, or open it from the AppImage or Flatpak.\n\n## Finish in the app\n\nBefore the user presses Print, tell them what to check: the printer, plate\ntype, nozzle, filament and AMS mapping, a clear build plate, and someone nearby\nfor the first layer. Never report a print as started: the agent can't see the\nprinter, and the app shows the job and its progress.\n\n## Out of scope\n\nStarting, pausing or cancelling prints without the user, and reading printer\nstatus: Bambu Lab's firmware takes those from other software only through Bambu\nConnect, or in Developer Mode, which is LAN-only and disconnects the printer\nfrom Bambu Cloud. For slicing, use `$gcode` or Bambu Studio.\n","frontmatter":{"name":"bambu-labs","description":"Send prints to Bambu Lab printers through Bambu Connect, Bambu Lab's official app for printing from other software, or Bambu Studio. Use when the user wants to print a sliced `.gcode.3mf`, a Bambu `.gcode` or an unsliced model on a Bambu Lab printer, over Bambu Cloud or LAN. The agent opens the file in the app; the user picks the printer and starts the print there.","license":"MIT"},"isInternal":false,"tokens":943,"sizeBytes":3443},{"name":"openai.yaml","path":"skills/cad/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"CAD\"\n  short_description: \"Generate and validate CAD artifacts.\"\n  default_prompt: \"Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","isInternal":false,"tokens":59,"sizeBytes":287},{"name":"animation-deformation.md","path":"skills/cad/references/animation-deformation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/animation-deformation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Tube deformation and morph export\n\nRead this reference for flexible swept bodies in an embedded animation, or for\nexporting that deformation into GLB. The [motion reference](kinematics.md)\ncovers ordinary joints, poses, clip declarations and video rendering.\n\n## Deforming an existing tube\n\nCall `.deformTube()` on an animation handle for a continuous swept STEP body:\n\n```javascript\n// Inside update(t, m), with restPath and posedPath defined for this frame:\nm.get(\"tendon\").deformTube({\n  rest: restPath,\n  path: posedPath,\n  twistDeg: 0,\n  maxSegmentLength: 1,\n});\n```\n\nBoth paths are `{normal, segments}` in assembly coordinates. `normal` is a\nrequired transverse frame seed on each path. Segments must connect tangentially:\n\n| Segment | Fields |\n| --- | --- |\n| Line | `{kind: \"line\", start, end}` |\n| Arc | `{kind: \"arc\", center, axis, start, sweepDeg}` |\n| Cubic Bezier | `{kind: \"bezier\", points: [p0, p1, p2, p3]}` |\n\nPositions are vec3 millimetres; angles are degrees. Normalized arc length maps\nthe rest path to the posed path. Check tendon length, bend radius and collisions\nseparately: deformation does not solve those constraints. `twistDeg` rotates\nauthored cross-sections; it does not calculate spool payout.\n\n`maxSegmentLength` controls one-time longitudinal mesh refinement in millimetres\n(default `1`, minimum `0.05`). The rest mesh, normals and topology remain\ncontinuous, and shared source buffers remain immutable. Omitting deformation in\na later frame restores the rest shape.\n\nOptional `braid: {pitch: 0.8, depth: 0.02, strands: 8}` adds procedural fiber\ncolor and normal relief over the swept core. Pitch and depth are millimetres;\nthe strand count must be even. This is a surface finish, not additional CAD or\ncollision geometry.\n\n## Exporting deformation to GLB\n\nAnimated GLB refuses tube deformation by default. Request morph targets explicitly:\n\n```bash\ncadgen glb build STEP/hand.step GLB/animated/hand.glb \\\n  --animation '{\"clip\": \"fist\", \"fps\": 24, \"seconds\": 6,\n                \"deform\": \"morph\", \"deformTolerance\": 1.0}'\n```\n\nEach deforming node gets per-vertex position and normal deltas against one base\nmesh, plus a weights channel. Interpolation between baked poses can differ from\nthe procedural deformation. The exporter fits targets per occurrence against\n`deformTolerance` (millimetres, default `1.0`, range `0.01`..`10`), checking a\ngrid four times finer than `fps`, at least 96 Hz. This sampled check is not a\ncontinuous-time error guarantee. The export summary reports `targets`, `bytes`,\n`runtimeBytes`, `deviationMm` and `fitGridHz`.\n\nThe exporter refuses a bake exceeding 512 MiB of estimated morph playback\nmemory: 32 bytes per vertex per target, or 16 without normals. To reduce it,\nrelax `deformTolerance`, shorten `seconds`, increase `--mesh-tolerance`, or\nincrease the clip's `maxSegmentLength`, as the required fidelity permits.\n\nLimitations:\n\n- Procedural braid shading is not exported. The GLB retains the tube's geometry\n  and motion with a smooth surface.\n- Changing a tube's `rest` path during the clip is refused because morph targets\n  share one base mesh. Use `.rotate()` or `.translate()` for rigid motion.\n- A tube held in a constant deformed pose exports in that pose without morph\n  targets. `\"deform\": \"rest\"` instead explicitly freezes tubes at rest and warns;\n  use it only when that is the intended output.\n\nThe CAD Viewer plays baked morph animation from GLB, but the original clip's\nprocedural controls remain in the STEP sidecar. Render a video when the output\nneeds effects GLB cannot carry; see [motion review](kinematics.md#reviewing-motion).\n","isInternal":false,"tokens":888,"sizeBytes":3622},{"name":"build123d-modeling.md","path":"skills/cad/references/build123d-modeling.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/build123d-modeling.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# build123d modeling patterns\n\nRead this file when constructing or repairing native CAD geometry. Model scripts\nreturn build123d shapes; decorators declare the output files. See the\n[model contract](step-generation.md) for execution and composition.\n\n## Construction choices\n\nChoose a construction that expresses the controlling dimensions directly and\npreserves the intended geometry. Builder contexts and algebraic modeling are\nboth valid; profile operations, solid features and surface construction suit\ndifferent shapes. The examples and remedies below are options, not a required\nfeature sequence.\n\nUse meaningful parameters and functional datums. Keep separately manufactured\nor moving parts identifiable in an assembly; fuse bodies where the design calls\nfor one continuous part. Prefer solids for physical parts, but do not close or\nthicken surfaces when the user requested surface geometry.\n\nOperation order depends on the design. Applying finishing features late often\nmakes selectors simpler; constructing them in a profile can be more robust for\nsome outlines. Through-cut tools should span the intended material; choose any\nextra extent relative to the geometry and nearby features, not a fixed distance.\n\n## Selection and topology\n\nAn **occurrence** is a placed assembly node. Its geometry contains bodies,\nfaces, edges and vertices. For inspection, keep the canonical `ref` and\n`occurrence_ref`; see [inspection](inspection-and-validation.md) for enumeration.\nNumeric selectors belong to one saved revision.\n\nFor construction, select by the feature's geometry or datum where practical:\nnormal, axis, plane, position or curve type. Re-evaluate selections after an\noperation that changes the relevant topology. Select a fillet's or chamfer's\nedges from the solid it rounds, after that solid's last operation: an\noperation returns new native topology wherever it changed the solid, so an\nedge held from before it may not be the solid's own. A fillet silently skips\nsuch an edge, and fails when none is left; a chamfer fails on it. Shape\nequality is build123d's own and compares native handles, so\n`edge in solid.edges()` is true only for the solid's own edge.\n\n## Labels and assemblies\n\nUse concise, meaningful native labels on exported parts and assembly occurrences,\nincluding repeated placements such as `m3_screw:front_left`:\n\n```python\n# Inside an assembly model, after constructing and placing its parts:\nbase.label = \"base\"\nlid.label = \"lid\"\nassembly = bd.Compound(children=[base, lid], label=\"electronics_enclosure\")\n```\n\nLabels describe role or placement; they need not repeat topology categories.\nA feature fused into or cut from a body does not retain a separate occurrence\nlabel. Keep its intent in parameters, construction datums and geometric checks.\nDo not promise persistent face/edge names through STEP export.\n\nSee [positioning](positioning.md) for assembly datums, joints, explicit transforms\nand checks of saved mating relationships. Source joints position geometry;\n[kinematics](kinematics.md) separately declares motion for the viewer.\n\n## Colour and finish\n\nNative `Color` channels are linear RGB. Use `cadgen.srgb` for a colour specified\nas a display hex value:\n\n```python\nfrom cadgen import srgb\n\nbody.color = srgb(\"#2E3742\")\nglass.color = srgb(\"#38414D\", 0.42)\n```\n\nSet native colour on leaf occurrences: a colour on a group compound does not\npropagate to its leaves in the render tree. Named material assignments can target\na group and expand to its leaves:\n\n```python\n@step(materials={\n    \"definitions\": {\n        \"cast\": {\"name\": \"As-cast steel\", \"roughness\": 0.85, \"metalness\": 0.2},\n        \"ground\": {\"name\": \"Ground steel\", \"roughness\": 0.25, \"metalness\": 0.9},\n    },\n    \"assignments\": [\n        {\"targets\": [\"#housing\"], \"material\": \"cast\"},\n        {\"targets\": [\"#journal\"], \"material\": \"ground\"},\n    ],\n})\ndef gearbox():\n    ...\n```\n\nMaterial keys are optional `name`, `baseColor`, `roughness`, `metalness`,\n`clearcoat`, `clearcoatRoughness`, and `opacity`. Numeric channels are finite\n0..1 values; `baseColor` is `#RRGGBB`. STEP's sidecar carries these finishes;\n`baseColor` does not change STEP colours or geometry bytes. Finishes inherit\nthrough cached child composition and are consumed by Render and GLB export.\nNormal CAD display keeps colour/opacity with workbench shading. Dynamically\nsetting `cad_material` is unsupported.\n\n## Placement and frame pitfalls\n\n- **Rotation frame:** do not infer a local rotation axis from Euler-angle\n  spelling. Check the transformed basis for a non-global `Plane`; explicit\n  direction vectors or an axis-angle construction can make the intended frame\n  clearer. A valid loft can still join incorrectly oriented sections.\n- **Primitive alignment:** `align=None` preserves the primitive's raw datum;\n  it does not mean centered. For example, a cylinder is based at Z=0 while a\n  box starts at a corner. Set alignment deliberately when placement depends on it.\n- **Absolute versus relative placement:** `.located(loc)` replaces location;\n  `.moved(loc)` and `Location * shape` compose it. Applying `.located()` after\n  a rotation can therefore discard the orientation. For placed child models,\n  use `.moved()` or multiplication to preserve shared geometry; `.located()`\n  copies it and loses the child link. Absolute placement remains a native API\n  option when replacement and copying are actually intended.\n\n```python\n# A local box, rotated before placement:\nbox = bd.Solid.make_box(1, 1, 1)\nrotated = box.rotate(bd.Axis.Z, 90)\nplaced = bd.Location((5, 0, 0)) * rotated  # retains the rotation\n```\n\nIn build123d 0.11.1, a moved assembly compound's `intersect()` can traverse\nunmoved `.children` despite `.wrapped` carrying the correct placement. Extract\nplaced `.solids()` or operate on the placed OCCT shapes when affected.\n`cadgen.geometry.overlap_volume` takes individual solids and queries private\ncopies of their placed geometry. Check the actual pose when diagnosing an\nunexpectedly constant interference result.\n\n## Loft and sampled-profile pitfalls\n\nFor sampled sections, keep feature order, edge correspondence and orientation\nconsistent across stations. Twisting or rippling can be present in valid\ngeometry, so check the intended sections and review the surface.\n\n- Corresponding feature rails or consistent samples per band can prevent a\n  feature from drifting between sections. Equal sample counts alone do not\n  guarantee a correct loft.\n- Independently easing every interval to zero slope can introduce unintended\n  flat spots. Choose interpolation for the desired continuity. Smooth measured\n  noise only within the task's geometric tolerance.\n- A disconnected or self-crossing section can produce misleading downstream\n  loft failures. Inspect section wires as well as face validity. Increasing\n  prefixes or adjacent section pairs can localize a failing region.\n- A ruled loft can help diagnose a smooth-loft failure, but changes the surface;\n  use it as a replacement only if its faceting and continuity meet the design.\n- For fields built by blending sampled component profiles, a component ending\n  inside its neighbor may leave a steep wall. Inspect the blend function and\n  continuity before increasing sample density. Adjust component overlap or the\n  blend only if it preserves the required boundary.\n\n## Boolean and finishing pitfalls\n\nKernel behavior depends on topology, tolerances and the installed build123d/OCP\nversion. Diagnose the failing operation before changing the construction.\n\n- **Many tools:** batch independent cuts when that avoids repeatedly rebuilding\n  the same intersection network. Overlapping tools may need staged operations;\n  sequential cuts are legitimate when later features depend on earlier ones.\n  Restrict excessive tool extents when they create unnecessary intersections.\n- **Large spline surfaces:** even batched cuts can be expensive. Localize the\n  expensive operation with timing or a stack sample, then consider simpler\n  surfaces, smaller tool regions or a different construction of the same feature.\n  A groove's screen size is not a reason to remove it from the CAD model.\n- **Near tangency or coincident boundaries:** inspect the removed/added region\n  and solid count, not only a successful return. A direct profile construction\n  may avoid an unstable Boolean intersection, when it describes the same shape.\n- **Fillets and chamfers:** check selected edges and available local space.\n  Smaller radii, different feature ordering, grouped selections or a profile\n  bevel are possible remedies, subject to the required dimensions. Do not\n  silently reduce or omit a specified feature through a retry ladder.\n- **Tangent chains or complex outlines:** some kernel versions fail or crash\n  during finishing operations. Isolate a reproducible case; a profile bevel or\n  separately constructed transition is a workaround, not a ban on 3D fillets.\n- **2D algebra:** inspect intermediate types. A `ShapeList` participates in\n  Python list operations, which may concatenate instead of fusing geometry.\n  Check wire winding/face normals before extrusion, especially after mirroring\n  sampled points. Confirm an intersection is nonempty before using it as a cut.\n- **Dense periodic splines:** build123d 0.10/OCP 7.9 have shown failures in taper,\n  inward offset and coincident-face fusion for some densely sampled profiles.\n  If reproduced, simplify the representation or construct the offset/transition\n  explicitly and verify its distance, continuity and topology. Numerical offsets\n  are an option, not a universal replacement for kernel offsets.\n\nPreserve the requested geometry while repairing it. If an exact feature cannot\nbe built, report the limitation or seek a design decision rather than quietly\nsubstituting an approximation.\n\n## Validity and visual artifacts\n\nTopology validity does not prove positive orientation, the requested shape, or\nsuitability for a later Boolean. Check signed volume per intended solid;\naggregate volumes can hide inverted members. Open shells are legitimate when\nsurfaces were requested.\n\nUse `cadgen.geometry.topology_errors`, `boundary_edges` and, when relevant,\n`self_intersections` on saved geometry. During a failing construction, check\nintermediates around the suspect operation. A validity gate inside a model\nbody — a retry ladder that accepts a fillet only when the result is sound, a\nstage check on a casting — uses build123d's `shape.is_valid` and\n`cadgen.geometry.is_sound` (the `BRepAlgoAPI_Check` verdict, which also\nidentifies Boolean-suitability issues such as tiny edges). A check costs\nkernel time, so gate where a failure is plausible rather than after every\nsimple operation. Any repair must preserve the dimensions being checked. See\n[inspection](inspection-and-validation.md).\n\nA periodic cylinder or revolved face has a seam edge that may appear in CAD\nlinework. Use shaded display or another camera to distinguish a display seam\nfrom a crack. Do not rotate a finished, datum-constrained part solely to hide\nits seam.\n\nFor further failure diagnosis and before/after checks, see [repair loop](repair-loop.md).\n","isInternal":false,"tokens":2353,"sizeBytes":11128},{"name":"cad-brief.md","path":"skills/cad/references/cad-brief.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/cad-brief.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Interpreting a CAD request\n\nUse this reference when modeling from prose, reference images or technical\ndrawings. Record only the dimensions, interfaces, outputs and assumptions needed\nto construct and check the requested geometry. A simple part may need a sentence;\na complex assembly may benefit from a dimension/interface checklist. There is\nno required brief format, and direct inspection or export needs no modeling brief.\n\nClarify missing or conflicting information when it materially affects the result,\nespecially a mating interface or required scale. Otherwise proceed with explicit\nassumptions. Do not ask the user to fill out a template.\n\n## Reference images\n\n- Distinguish reproducing a part from using an image as design inspiration.\n- Establish scale from a supplied dimension or known reference when available.\n  Unscaled images can guide proportions; they do not establish fit dimensions.\n- Record inferred dimensions as assumptions. Ask for scale when fit depends on it.\n- For reproduction, compare a snapshot from a similar viewpoint with the image.\n\n## Technical drawings\n\n- Read units, projection convention, revision and notes; identify each view and\n  section before mapping dimensions to model axes.\n- Prefer explicit dimensions over image proportions. Resolve conflicting\n  dimensioned sources rather than silently choosing one.\n- Preserve multiplicity (`4X`), `TYP.`, threads, counterbores, countersinks and\n  internal depths shown in sections. Track the requirements for later checks.\n- Derive unspecified geometry from constraints where possible. Treat dimensions\n  estimated from a drawing image as assumptions, never as verified callouts.\n- Verify the specified dimensions against saved geometry using Python\n  measurements, or explicitly report those not verified. See\n  [inspection and validation](inspection-and-validation.md).\n\nBefore modeling, identify the relevant source/output paths, units, coordinate\nconvention and functional interfaces. Choose construction and validation methods\nthat make the controlling dimensions easy to express and check.\n","isInternal":false,"tokens":387,"sizeBytes":2085},{"name":"inspection-and-validation.md","path":"skills/cad/references/inspection-and-validation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/inspection-and-validation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Inspecting and checking saved CAD in Python\n\nUse small Python scripts against the saved STEP/STP. `cadgen step inspect`\nand `cadgen.step.inspect` have been removed. Choose the relevant entities,\nmeasurements and acceptance criteria in the script; there is no replacement\ninspection command or universal validation report.\n\nPut exploratory scripts in the CAD project's ignored `tmp/`, for example\n`tmp/check_clearance.py`; system `/tmp/` is also fine for disposable work.\nKeep reusable design checks in `checks/` (for example `checks/clearance.py`),\nor use the project's existing test location. Commit those checks so they can\nbe rerun after a model changes. Create the directory only when needed.\nKeep checks outside `src/` model directories and raw format folders such as\n`STEP/`. Examples below use paths relative to the CAD project root; run from\nthere, or anchor input paths explicitly when a script must run elsewhere.\n\nReport what was measured, the units, the threshold and the selected geometry.\nA failed computation is not a passing check, and an untested relationship has\nno verdict. Run the model first when you want to check newly authored geometry.\n\n## Opening and selecting geometry\n\n```python\nfrom cadgen import read_step, read_scene\n\nshape = read_step(\"STEP/bracket.step\")  # native build123d geometry\nscene = read_scene(\"STEP/assembly.step\")  # hierarchy and exact selector refs\n\nprint(scene.document_hash)\nfor occurrence in scene.leaves():\n    print(occurrence.ref, occurrence.label, occurrence.prototype_id)\n\n(housing,) = scene.resolve(\"#housing\")\nprint(housing.ref, housing.kind)\nsolid_or_compound = housing.shape()\nfor face in housing.entities(\"face\"):\n    print(face.ref, face.shape().area)\n```\n\n`read_step` keeps its existing name. `read_scene` replaces the old\n`load_step_scene` helper. Inside a model, the document either reads is a build\ninput, as every file a build reads is. Paths expand `~` and resolve relative to the working directory.\n\n- `scene.roots` and `occurrence.children` are tuples. `scene.leaves()` yields\n  geometry occurrences, including repeated copies. A leaf can hold multiple\n  solids; the reader does not infer manufactured parts.\n- `scene.resolve(ref)` returns a tuple of selections, one per ref in the line\n  (see [Reference syntax](#reference-syntax)). A selection has `ref`, `kind`,\n  `occurrence_ref`, `shape()` and `entities(kind)`. Kinds for enumeration are\n  `\"shape\"`, `\"face\"`, `\"edge\"`, `\"vertex\"`. Shape entities (`sN`) are solids,\n  or shells when no solids exist, or the leaf geometry itself.\n- Every `shape()` is caller-owned exact build123d geometry in the document's\n  world coordinates, including ancestor placements. A group produces an\n  unfused compound. Modifying the result cannot change the scene or another\n  selection. Keep the returned shape when making several queries about it.\n- `prototype_id` identifies shared geometry within this scene, allowing a\n  script to avoid repeating a suitable body-local check. It is not a part\n  number or an identity across revisions. Numeric self-intersection tests can\n  behave differently at different placements; state which placements ran.\n- A scene is bound to one document hash. Replacing the file does not change an\n  already opened scene. Reopen it to inspect the new revision. Selector IDs\n  are revision-scoped, not persistent feature names.\n- Opening reuses the canonical BREP cache, decoding prototypes on demand.\n  No display surfaces, tessellation, source discovery or source execution are\n  needed. Cached inventory and occurrence lookup defer the build123d import\n  until native geometry is requested. Keep the scene open: repeated reads\n  still hash the document bytes, and fresh Python processes pay import costs.\n- The scene describes the saved placement. It does not evaluate sidecar\n  kinematics or animation poses.\n\n### Reference syntax\n\n```text\n#o1.2          occurrence or subassembly\n#o1.2.f7       canonical face 7 on that leaf occurrence\n#housing.e3   canonical edge 3 on the occurrence labelled housing\n#f7           face 7, only when the scene has exactly one leaf\npart.step#o1  a file-prefixed ref matching the opened STEP\npart.step#o1.2.f7,o1.2.f8  several refs, as the viewer copies a multi-selection\n#o1.2.f7,f8   a bare entity after an occurrence or label belongs to it\n```\n\n`resolve()` takes a whole copied line, one ref or a comma list, and returns a\ntuple with one selection per ref, in order; any ref that does not resolve\nraises. Unpack a single ref: `(face,) = scene.resolve(\"#o1.2.f7\")`.\nTo enumerate a subassembly's faces, resolve it, `(group,) = scene.resolve(\"#group\")`,\nand call `group.entities(\"face\")`; a group has no independent\nface ordinals. Enumeration preserves canonical occurrence IDs and never\nmerges coincident instances. Face and edge IDs agree with the viewer; vertex\nIDs enumerate native STEP vertices (the viewer currently exposes no vertex\ntable).\n\nLabels use letters, digits, `_` and `:`, and cannot start with a digit or\ncollide with numeric reference syntax. Other labels remain accessible through\nnumeric IDs. Duplicate labels receive numbered aliases in occurrence order;\nresolving the ambiguous bare label raises and lists candidates. No fuzzy\nmatching or choosing the first match.\n\nA copied reference's file prefix is the file's absolute path, with the file's real\nname and extension (in quotes when it holds a space or `#`). `resolve()` reads the\nprefix as a path, with `~` expanded, links followed and a relative one read from the\nworking directory, and it must name the opened document.\nA relative prefix that names no file from where you run still matches the end of\nthe document's path. A prefix naming a different document is rejected. Do not\nguess between ambiguous files.\n\n## Measurements\n\nMost measurements already have native build123d interfaces:\n\n```python\n# p, q: chosen points; u, v, direction, normal: explicit vectors\nlength = (q - p).length\nsigned_offset = (q - p).dot(direction.normalized())\nangle_degrees = u.get_angle(v)\nsigned_angle_degrees = u.get_signed_angle(v, normal)\n\n# Select the actual geometric feature that defines the dimension.\ncenter = circular_edge.arc_center\nradius = circular_edge.radius\ndiameter = 2 * radius\nlength = edge.length\narea = face.area\nvolume = solid.volume\nsize = shape.bounding_box().size\n```\n\nA bounding box dimension is an envelope, not a hole diameter or wall\nthickness. A surface centroid is not necessarily a datum. Use analytic\ncenters, axes and selected points when those define the requested dimension.\nThe default `bounding_box()` searches for tight bounds and can be expensive\non curved geometry. Use `bounding_box(optimal=False)` for a fast conservative\nenvelope when filtering candidate collision pairs, not for a tight dimension.\n\n```python\nfrom cadgen.geometry import closest_points\n\nresult = closest_points(a, b)\nprint(result.distance, result.point_a, result.point_b)\n```\n\n`closest_points(Shape, Shape) -> ClosestPoints` returns a nonnegative minimum\nset distance and one witness pair in the input frame. Solid overlap, contact\nand containment have distance zero; it does not measure penetration depth.\nWitnesses may be nonunique or internal to a solid. Pass selected faces/shells\nwhen asking about boundary separation. Minimum distance does not replace\ncenter spacing, angles, radii, lengths, areas or volume measurements.\n\n## Overlap and clearance checks\n\n`overlap_volume(Solid, Solid) -> float` measures the exact intersection volume\nof two finite, positively oriented solids. It returns zero for mere contact.\nIt applies no minimum volume, exclusions, part hierarchy or pass/fail policy.\nKernel failures raise; inputs remain unchanged. Length units from STEP are mm,\nso overlap volume is mm³. Tolerances in these scripts are design decisions,\nseparate from the kernel's numerical precision.\n\nFor example, checking every body pair inside a chosen subassembly:\n\n```python\nfrom itertools import combinations\nfrom cadgen import read_scene\nfrom cadgen.geometry import overlap_volume\n\nscene = read_scene(\"STEP/assembly.step\")\n(selection,) = scene.resolve(\"#housing_assembly\")\nbodies = [\n    (entity.ref, solid)\n    for entity in selection.entities(\"shape\")\n    for solid in entity.shape().solids()\n]\nif len(bodies) < 2:\n    raise ValueError(\"This interference check needs at least two bodies\")\n\nmax_overlap_mm3 = 0.01  # chosen for this design\nfailures = []\ntested = 0\nfor (ref_a, a), (ref_b, b) in combinations(bodies, 2):\n    volume = overlap_volume(a, b)\n    tested += 1\n    if volume > max_overlap_mm3:\n        failures.append((ref_a, ref_b, volume))\nprint({\"tested_pairs\": tested, \"limit_mm3\": max_overlap_mm3,\n       \"overlaps_mm3\": failures})\nif failures:\n    raise SystemExit(1)\n```\n\nAdapt pair selection to the question: for clearance around a moving carriage,\ncompare that carriage against relevant fixed geometry. List intentional\npress fits/excluded pairs explicitly. Do not infer exclusions from assembly\ndepth. For large pair sets, retain the shapes and compute conservative bounds\nonce per body. Disjoint bounds rule out overlap; intersecting bounds only\nidentify candidates for the exact query. For a clearance requirement use\n`closest_points(a, b).distance` and\ncompare against the specified minimum. A static check proves only that pose;\npath or motion checks need an explicit sampling or swept-volume strategy.\n\n## Geometry diagnostics\n\n```python\nfrom cadgen.geometry import is_sound, topology_errors, boundary_edges, self_intersections\n\nok = shape.is_valid                       # bool: BRepCheck passes (build123d)\nclean = is_sound(shape)                   # bool: the boolean kernel's argument check passes, expensive\nissues = topology_errors(shape)            # tuple[GeometryIssue, ...]\nfree = boundary_edges(shell)              # tuple[Edge, ...]\ncrossings = self_intersections(shape)     # tuple[GeometryIssue, ...], expensive\n```\n\n`GeometryIssue` has `code` and `entities`, containing owned build123d geometry.\nTopology codes are OCCT `BRepCheck_*` statuses; self-intersection codes are\n`BOPAlgo_SelfIntersect`. Failed/inconclusive checks raise `GeometryError`,\nnever an empty success result. None of these functions repairs geometry.\n\n`is_sound` is `BRepAlgoAPI_Check`'s verdict: BRepCheck-valid, no\nself-intersections, no too-small edges, an argument type a boolean accepts.\nIt is the gate a fuse or cut demands of an operand. Closure, solid count and\nsigned volume stay the script's own checks. `is_sound` is `False` for a null\nor empty shape, which the boolean kernel rejects as an argument. A check costs\nkernel time, so gate where a failure is plausible rather than after every\noperation.\n\nChoose checks appropriate to the artifact. For an intended closed solid,\ncheck topology, free shell edges and each solid's signed volume. A reversed\nsolid can pass topology validation but have negative volume; check individual\nsolids because aggregate volumes can cancel. Open shells can be valid when\nsurfaces are intended. No free edges establishes closure, not full manifold\nvalidity. Run self-intersection checks explicitly when required and report\nany checks you did not complete.\n\n## Mass properties\n\n```python\nfrom cadgen.geometry import mass_properties\n\nproperties = mass_properties([\n    (aluminum_body, 2.70e-6),  # kg/mm³; use the material density for the task\n    (steel_insert, 7.85e-6),\n])\nprint(properties.volume, properties.mass, properties.center_of_mass)\nprint(properties.inertia)\n```\n\n`mass_properties(Iterable[tuple[Solid, float]]) -> MassProperties` integrates\nuniform density per solid and sums the supplied bodies, including overlaps.\nDensities must be finite and positive. It does not infer materials or fuse\nbodies. The 3×3 inertia tuple is about the combined center of mass in the input\ncoordinate axes. With mm and kg/mm³, outputs are mm³, kg, mm and kg·mm².\n\nUse snapshots for visual review per `snapshot-review.md`. Turn visual concerns\ninto explicit geometric checks before claiming they have been resolved.\n","isInternal":false,"tokens":2685,"sizeBytes":12011},{"name":"kinematics.md","path":"skills/cad/references/kinematics.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/kinematics.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# CAD kinematics and animation\n\nRead this file when the user asks to articulate, pose, or animate a STEP\nmodel, or when designing or reviewing mates, couplings, pose presets, posed\nexports, or animation clips.\n\nThere are THREE systems with different lifecycles, deliberately independent:\n\n- **Geometry** is the module's constants and the factory the parameterless\n  model calls with them (`WIDTH = 10.0` … `return _bracket(WIDTH)`). Changing\n  one re-runs Python and rebuilds the outputs. They are not live in the viewer.\n- **Kinematics** is typed mates declared as PURE DATA via `kinematics=` on\n  the export decorators. It drives the viewer's pose sliders — no rebuild, no\n  Python at render time — and never moves the geometry a model writes. It\n  lives in the model's sidecar (`<name>.step.json`, written beside the\n  artifact), alongside any intrinsic material appearance.\n- **Animation** is choreography declared as a self-contained JavaScript ES\n  module string via `animation=` on `@step`. The build embeds it in\n  `<name>.step.json`; the viewer, snapshot door, and animated GLB export read\n  that document-bound copy. Animated GLB export hashes the source with the clip\n  request, so an animation edit invalidates that export. Animation targets\n  occurrences directly and knows nothing about mates. Like materials and\n  kinematics, it never changes STEP geometry or bytes.\n\n## Kinematics: typed mates\n\nKinematics is one sidecar section, independent of intrinsic material appearance.\nIt never moves saved geometry: the declaration describes how the written tree\narticulates, and the viewer poses it at render time. A STEP model with no\nkinematics, intrinsic material finishes or animation has no sidecar.\n\nOne `kinematics=` dict, closed keys `mates` / `couplings` / `poses`, on any of\n`@step`/`@stl`/`@glb`/`@threemf`. Each decorator's declaration stands alone\n(share a module-level dict; there is no cross-decorator inheritance).\n\n```python\nimport cadgen\nfrom cadgen import step\nfrom cadgen import build123d as bd\n\nKINEMATICS = {\n    \"mates\": [\n        cadgen.revolute(\"elbow\", parent=\"#upper_arm\", child=\"#forearm\",\n                        origin=(0, 0, 0), direction=(0, 0, 1), limits=(0, 150)),\n        cadgen.revolute(\"wrist\", parent=\"#forearm\", child=\"#hand\",\n                        origin=(80, 0, 0), direction=(0, 0, 1), limits=(0, 90)),\n    ],\n    \"couplings\": [cadgen.couple(\"curl\", {\"elbow\": 1, \"wrist\": 0.5}, limits=(0, 90))],\n    \"poses\": {\"straight\": {\"curl\": 0}, \"bent\": {\"curl\": 60}},\n}\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS)\ndef arm():\n    upper_arm = bd.Pos(-40, 0, 0) * bd.Box(80, 10, 10)\n    forearm = bd.Pos(40, 0, 0) * bd.Box(80, 10, 10)\n    hand = bd.Pos(90, 0, 0) * bd.Box(20, 10, 10)\n    upper_arm.label, forearm.label, hand.label = \"upper_arm\", \"forearm\", \"hand\"\n    return bd.Compound(children=[upper_arm, forearm, hand], label=\"arm\")\n\n\nif __name__ == \"__main__\":\n    arm()\n```\n\n- **Mate kinds**: `revolute` (degrees about an axis), `slider` (model units\n  along it), `cylindrical` (sub-DOFs `<name>.turn` and `<name>.travel` about\n  one axis), `fastened` (0-DOF rigid attachment — needed exactly when\n  occurrences are SIBLINGS in the instance tree, like a pin that must orbit\n  with its carrier; instance-tree children ride for free).\n- **`limits=(lo, hi)`** is required on every mate that moves, and both ends\n  must be finite: Position sliders and exports read the range. A joint that\n  turns freely takes a full range such as `(-180, 180)`; `math.inf` fails the build.\n- **`parent`/`child`** are occurrence refs: `#`-prefixed labels (canonical —\n  label parts with `cadgen.label_shape`) or occurrence ids. They must resolve\n  at build or the build fails; `read_scene(path).leaves()` lists saved geometry occurrences.\n  A label resolves **into linked children**: a part labelled inside a\n  sub-assembly you call (`#shoulder_yaw_servo` living in `base_link()`'s\n  tree) resolves to its occurrence under the link (`o1.1.1`), so an assembly\n  can mate parts of a sub-assembly it links without owning their geometry.\n  A ref may name a SUBASSEMBLY as well as a part — a labelled group `Compound`\n  is an occurrence in the instance tree, and mating it carries every part\n  beneath it. That is how a rocker-bogie chain is three mates instead of three\n  hundred. `scene.roots` and `occurrence.children` expose those groups. Targets may NEST — a part inside a mated group may carry its\n  own mate to a sibling (a servo's output horn bolted to the jaw it drives):\n  the DEEPEST mate naming a part owns it and moves it exactly once, and the\n  enclosing group carries only what no deeper mate claimed.\n- **`axis`** is a selector ref (`axis=\"#forearm.f2\"` — a cylindrical\n  face or circular edge yields its axis, a planar face its center+normal) or\n  literals (`origin=(x, y, z), direction=(x, y, z)`). Refs resolve ONCE at\n  build into world numbers; the viewer does arithmetic, never topology.\n- **ZERO IS THE ARTIFACT AS WRITTEN.** Every DOF's rest value is 0 — the\n  placement the author built. There is no `default=`; a presentation pose is a\n  preset. A model that must be WRITTEN at another configuration is authored\n  at that configuration (or is another model): no decorator argument moves\n  geometry.\n- **`couple(name, {dof: ratio})`** declares a virtual DOF gearing real ones\n  linearly and ADDITIVELY (above, setting `curl=x` adds `x` degrees to `elbow`\n  and `0.5*x` to `wrist`).\n  Exact gear trains are ratio arithmetic, not code.\n  A geared member BACK-DRIVES in the viewer: when exactly one coupling gears a\n  DOF with a nonzero ratio, its Position slider reads the effective value\n  (own + ratio x coupling), is labelled \"driven by <coupling>\", and dragging it\n  moves the COUPLING — `coupling = (target - own)/ratio`, clamped to the\n  coupling's limits — so sliding one gear turns the whole train. A member's own\n  value (from a preset or `--kinematics`) is never overwritten, and a DOF geared\n  by two couplings stays independent: that inverse is underdetermined, so the\n  viewer refuses it rather than guessing a split.\n- A declaration needs at least one mate (or coupling): a pose is a set of joint\n  values and a joint is what a mate declares, so `poses` alone declare nothing\n  and are refused. A part with no joints declares no `kinematics=`.\n- **`poses`** are named `{dof: value}` presets — all that remains of \"pose\"\n  as a concept.\n- The mate graph is a TREE: one parent mate per occurrence, no cycles.\n  Closed-loop linkages (four-bars) are out of scope by design — they need a\n  solver; the viewer evaluates pure forward kinematics from the sidecar's\n  numbers at render time.\n\n## Annotating a STEP you did not generate\n\nA document with no model script gets its kinematics from\n`cadgen step build IN OUT`, whose `--kinematics` takes the whole SPACE — the\nsame `{mates, couplings, poses}` vocabulary, as inline JSON or a `.json`\npath. `--materials` accepts the named material declaration as inline JSON or\na `.json` path, and `--animation` accepts a self-contained JavaScript module\nfile or source string that exports only `clips` (see below). The input is read\nwith OCCT and re-emitted by the canonical writer, so OUT's bytes are\ndeterministic whichever kernel wrote IN:\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\n**Wrapper script or `step build`?** A model that will keep changing belongs in a\nscript — a thin `@step` function that imports the foreign STEP and re-exports\nit, so the kinematics live beside the geometry decisions and every edit is one\n`python model.py`. Reach for `step build` when the geometry is fixed and not\nyours: a one-shot annotation or canonicalization of a vendor file. Re-running it\nis a no-op, editing only these annotations refreshes the sidecar without\nre-emitting a byte, and vendor metadata (PMI, GD&T) does not survive the trip.\n\n## Animation: the embedded module\n\nA STEP document may carry one self-contained JavaScript animation module in\nits unified sidecar. Author the module as a Python string and pass it to\n`@step(animation=...)`. It exports `clips` and nothing else; leave helpers and\nconstants unexported. The renderer refuses a module with any other export, and\nevery clip with it, so the build refuses it first, in the renderer's words:\n`arm.py::arm animation: unknown export ease — the renderer understands: clips`.\nThe module has no imports. For the arm above, add this constant and update its\ndecorator, keeping the same model body:\n\n```python\nANIMATION = r\"\"\"\nexport const clips = {\n  demo: {\n    label: \"Demo\",\n    duration: 8,          // seconds\n    loop: true,           // default\n    update(t, m) {        // called every frame; t in seconds\n      const angle = 60 * (1 - Math.cos(2 * Math.PI * t / 8));\n      m.get(\"forearm\").rotate([0, 0, 1], angle, [0, 0, 0]);\n      m.get(\"hand\").rotate([0, 0, 1], angle, [0, 0, 0]);\n    },\n  },\n};\n\"\"\"\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS, animation=ANIMATION)\ndef arm(): ...\n```\n\n- `m.get(target)` takes a LABEL (canonical) or occurrence-id refs\n  (`\"#o1.3.1\"`, comma lists; each id covers its whole subtree). Unknown\n  targets THROW — a typo never silently animates nothing. Labels here match\n  RENDERED PARTS only: to animate a whole group, name its occurrence id.\n- Handles support `.rotate(axis, degrees, origin=[0,0,0])`, `.translate(vec)`,\n  `.opacity(0..1)` and `.visible(bool)`. Successive transforms premultiply:\n  spin about a part's center first, then orbit the origin, and the spin rides\n  the orbit. For flexible swept bodies, see\n  [tube deformation and morph export](animation-deformation.md).\n- Every frame starts from rest and `update(t)` rebuilds the state — a pure\n  function of t, so scrub/loop/seek are free. No wall-clock, no state.\n- Animation targets occurrences independently of mates. Rerun the model after\n  editing its animation to refresh the sidecar. Literal annotation edits may\n  reuse cached geometry; computed or imported annotations can require a rebuild.\n  See [annotation caching](step-generation.md#annotation-caching).\n- The model build validates the declaration, exports included, and embeds it.\n  A model without `animation=` is simply a model without animation.\n- Targets are checked at LOAD, against the compiled tree: every clip's\n  `update(0, m)` runs once when the module loads, and a label or occurrence\n  id no part carries is reported in the viewer's Issues (`Animation\n  unavailable`, in the file's panel) and in\n  `snapshot --animation`'s error — not at the first frame that reaches it.\n- Mesh-only models (no `.step`) have no document sidecar; animation is a\n  STEP-document concern.\n\n## Reviewing motion\n\nReview motion interactively in the viewer or render a clip video as described\nbelow. For a still of the arm example's configuration, render at DOF values:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics '{\"curl\": 60}'\n```\n\n`--kinematics` is named for the `kinematics=` block it drives, and takes\neither spelling: `{dof: value}` JSON, or the NAME of a pose the model\ndeclares under `poses`. A name is checked against the declaration, so a typo\nfails with the poses this model actually has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics bent\n```\n\nFor still evidence of a CLIP, freeze one frame: `--animation` names a clip\nthe document sidecar's embedded animation declares and `--time` the\nmoment in seconds (default 0). The frame\nis composed exactly as the viewer composes it: `--kinematics` sets the base\npose, and the clip's `update(t, m)` is evaluated at that time on top of it.\nA clip name the model does not declare fails with the clips it has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo_t2.png --animation demo --time 2.0\ncadgen step snapshot STEP/arm.step tmp/demo_bent.png --kinematics bent --animation demo --time 2.0\n```\n\nIn a JSON job the request is one field, `\"animation\": {\"clip\": \"demo\",\n\"time\": 2.0}`, beside `\"kinematics\"`; the Python door takes the same object\n(`step.snapshot(..., animation={\"clip\": \"demo\", \"time\": 2.0})`) or the clip\nname with `time=`.\n\n### Rendering the whole clip\n\n`--video` renders the SPAN instead of a moment, into the `.mp4` or `.gif` the\nOUT names. Everything else is unchanged — same unified display settings, camera\nand size profile as a still, and the same `--kinematics` base pose underneath:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo.mp4 --animation demo --video '{\"fps\": 30}'\ncadgen step snapshot STEP/arm.step tmp/demo.gif --animation demo \\\n  --video '{\"fps\": 12, \"seconds\": 3, \"start\": 1.5, \"quality\": \"draft\"}'\n```\n\nThe request's keys, all optional:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `fps` | `30` | frames per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to render |\n| `start` | `0` | seconds into the clip where the video begins; must be inside it |\n| `quality` | `review` | `draft`, `review`, or `high` |\n| `loop` | `true` | GIF only — an `.mp4` carries no loop count, so `\"loop\": false` on one is refused |\n\nThe span is measured against the clip rather than trusted, because the clip\nevaluator ANSWERS a time past the end instead of refusing it: a clip that does\nnot loop holds its final pose, so an overrunning span buys frames that are all\none still image, and a looping clip wraps, so it renders a different span than\nthe one asked for. A `start` at or past the end is refused; an explicit\n`seconds` that overruns a clip which does not loop renders and warns. Hence the\ndefault: a looping clip gets one whole cycle from wherever it starts, and a clip\nthat stops gets the part of it that is left. `fps * seconds` is capped at 7200\nframes — every frame is a full-size PNG on disk before ffmpeg runs.\n\nWidth and height come from `--width`/`--height`/`--size-profile` like any\nother render; there is no size key here. `--video` needs `--animation` (it\nrenders a clip) and refuses `--time` (that freezes one frame instead), and it\nneeds **ffmpeg** on `PATH` — or `CADGEN_FFMPEG` pointing at one. A missing\nencoder is reported before the first frame is drawn, never after minutes of\nrendering. A GIF stores its frame delay in hundredths of a second, so a player\nshows the nearest rate to the one asked for; `.mp4` is exact.\n\nThe camera is fitted ONCE, to everything the clip covers, so the model moves\nand the frame does not. That is why a long clip is worth trimming with\n`start`/`seconds`: framing the whole of a clip that travels a long way leaves\nthe interesting part small.\n\nIn a JSON job the request is a `\"video\"` object beside `\"animation\"`, and a\nvideo job carries exactly one output:\n\n```json\n{\n  \"input\": \"STEP/arm.step\",\n  \"animation\": { \"clip\": \"demo\" },\n  \"video\": { \"fps\": 24, \"quality\": \"high\" },\n  \"outputs\": [{ \"path\": \"tmp/demo.mp4\", \"camera\": \"iso\" }]\n}\n```\n\nThe result names the file and what it covers — `saved video: tmp/demo.mp4\n(120 frames, 30 fps, 4s)` — so a wrong clip or a wrong span shows up without\nopening it. Still snapshots remain the evidence for a POSE; a video is for\nmotion a still cannot show.\n\n### Exporting the clip INSIDE a GLB\n\nA video is pixels. `cadgen glb build --animation` writes the motion itself: the\nclip is sampled into glTF node animation, so whatever opens the `.glb` — Blender,\na three.js viewer, a browser's model preview — plays it. There is no camera, no\nquality and no encoder here, and `fps` means something different: the SAMPLING\nrate of the baked keyframes, not a playback rate.\n\n```bash\ncadgen glb build STEP/arm.step GLB/animated/arm.glb --animation demo\ncadgen glb build STEP/arm.step GLB/animated/arm.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\nThe request is a clip name, or an object whose keys are all optional but `clip`:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `clip` | — | the clip to bake; required |\n| `fps` | `30` | keyframe samples per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to bake |\n| `start` | `0` | seconds into the clip where the span begins; must be inside it |\n| `drop` | `[]` | effects to bake STATIC instead of refusing: `opacity`, `visible` |\n| `deform` | `refuse` | what to do with `.deformTube()`: `refuse`, `morph`, `rest` |\n| `deformTolerance` | `1.0` | `morph` only — millimetres the baked tubes may sit from the clip's own deformation, `0.01`..`10` |\n\nThe span is resolved exactly as `--video`'s is — a looping clip defaults to one\nwhole cycle, a clip that stops gets what is left of it, and `fps * seconds` is\ncapped at 7200 samples. The exported animation is re-based to zero, so `start`\npicks where in the CLIP the span begins and the file still opens at t = 0.\n\n**What glTF carries, and what it will not:**\n\n| clip effect | in the GLB |\n| --- | --- |\n| `.rotate()` | sampled rotation channel on that occurrence's node |\n| `.translate()` | sampled translation channel on the same node |\n| `.rotate()` about a pivot | sampled rotation and translation channels |\n| `.opacity()` | **refused.** glTF has no animated opacity. `\"drop\": [\"opacity\"]` bakes the value at `start` as a material alpha, and warns |\n| `.visible()` | **refused.** Same reason. `\"drop\": [\"visible\"]` omits whatever is hidden at `start`, and warns — an occurrence dropped this way loses its motion too, because a node that is not in the file cannot be animated |\n| `.deformTube()` | **refused by default.** Per-vertex motion, not a node transform. `\"deform\": \"morph\"` bakes it as glTF morph targets; see [deformation](animation-deformation.md); `\"deform\": \"rest\"` ships those tubes at rest shape and warns |\n\nNothing is dropped quietly: an effect the file cannot carry stops the export and\nnames the occurrences, so a hand whose tendons froze on the way out is a refusal\nrather than a finished-looking file. Render the clip with\n`cadgen step snapshot --animation <clip> --video` when the motion is one of\nthose — `--video` needs the clip named too, so both flags go together.\n\nFor `.deformTube()` authoring, morph fitting, memory limits and braid export\nlimitations, read [tube deformation and morph export](animation-deformation.md).\n\nThe CAD Viewer plays a GLB's embedded rigid, skinned and morph animation from its\nfile sidebar's Animation section, in the tools view and Preview alike. These are baked clips: the STEP\nsidecar module's procedural controls are not available in the exported GLB.\n\nAn animated export writes ONE node per occurrence instead of the flat,\ncolour-grouped mesh a static one writes, so the file is bigger and its parts are\nindividually addressable. Only occurrences the clip actually MOVES get channels;\nan occurrence held at a constant offset carries it on its node and nothing else.\nFreshness folds the clip request and the sidecar animation source into the export's key, so\nediting the choreography re-exports even though the STEP has not changed.\n\n`--animation` is GLB's alone, and the CLI is generated from each door's\nsignature, so `cadgen stl build` and `cadgen 3mf build` have no such flag at all\n— they exit 2 with `unrecognized arguments: --animation`. Neither format has\nanywhere to put a clip; export it as `.glb`, or render it with\n`cadgen step snapshot --animation <clip> --video`.\n\nAnimated GLB requires an explicit OUT. A static export without OUT writes one\nsibling `.glb` beside the STEP; it does not read model output declarations.\nChoose a separate destination for the animated file when retaining both.\n\nChoose pivots and axes for the intended motion. For hinged motion, use the\nphysical hinge axis. Verify the relevant dimensions, alignments and clearances\nwith geometry checks.\n","isInternal":false,"tokens":5175,"sizeBytes":19991},{"name":"migrations.md","path":"skills/cad/references/migrations.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/migrations.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Migrations\n\nRead this file when source, command syntax or sidecars target a different\ncadgen version. First run `cadgen doctor <skill-dir>` to check the installed\nversion against the skill's pin. Follow the specific error and current command\nhelp; a modeling failure alone does not establish version skew.\n\ncadgen uses hard interface cutovers. A retired interface may fail with a\nteaching error naming its replacement; it is not a compatibility alias.\nThe retired inspect CLI is replaced by Python checks using `read_scene` and\nnative geometry; see [inspection](inspection-and-validation.md).\n\n## A migration message is a task\n\nWhen a build, snapshot or validation reports that a model needs\nmigrating (a sidecar refused for its schema version, a leftover companion file\nthe build warns about, a retired decorator argument or command named by a\nteaching error), do the migration then, as part of the work in hand. Do not\nroute around it, silence it, or leave it for later: the CAD Viewer does not\nflag a stale model, so an unmigrated one simply loses its kinematics,\nmaterials and animation and looks like a plain part.\n\n- A refused sidecar (`unsupported sidecar schema N (expected M)`) is fixed by\n  running the model's script again (`python <model>.py`), which writes a current\n  sidecar beside the STEP. For an imported STEP that has no script, re-annotate\n  it with `cadgen step build`. Never edit `schemaVersion` by hand.\n- A leftover companion render module beside the STEP (a `.js` file named after\n  the document; the build warns it \"is a retired render module\") is read by\n  nothing, so its clips are missing from the model. Move them into\n  `@step(animation=...)` ([kinematics](kinematics.md)), delete the file and\n  rebuild.\n- After migrating, confirm it took: the sidecar declares the current schema and\n  the document's hash, the build printed no migration warning, and the model\n  articulates (a `cadgen step snapshot --kinematics …` pose differs from rest,\n  or the Viewer shows a Position section in the file's panel, the Position\n  tool and, for a model with routines, its routines playing in preview).\n- If the script itself no longer runs on the installed cadgen, migrate the\n  script using the sections below, then rebuild.\n\n## When to suspect skew\n\n- **A model script runs, exits 0, and writes nothing.** An older source carries\n  no decorated function and no entry point of its own, so Python defines a\n  function and exits. Nothing looks for an entry point by name.\n- **A command or flag you are sure of comes back unknown**, and the help lists\n  an unfamiliar set. Building a model is running its script; there is no\n  generation verb. Consult current help and any replacement named by the error.\n- **A sidecar is refused for its schema version.** Sidecars are never upgraded\n  in place and never partially read, because a wrong-shaped one would cost a\n  model its kinematics silently.\n- **A model that used to articulate renders inert**, presenting as a plain\n  document with no pose and no animation. Nothing is discovered by convention:\n  kinematics and animation are `kinematics=` and `animation=` on the model's\n  decorator, and the build puts both in the document's sidecar. A companion\n  JavaScript file beside the document is no longer read. A generated model's\n  build warns about the leftover file and names the replacement decorator;\n  the warning does not stop the build.\n- **Meshes come out visibly coarser or finer, with no error.** Mesh tolerance\n  kept its name and changed meaning — chord tolerance is a fraction of the\n  component's bounding diagonal, not an absolute length — so a value carried\n  across from an older project is wrong in proportion to the part's own size.\n  A carried-over value above `0.05` is refused outright with the conversion\n  (X mm on a part whose diagonal is D mm is X/D); a smaller one is accepted, so\n  check it against the part's size.\n\nA migrated source may still have incompatible saved outputs. Rebuild or\nre-annotate the affected document as the error directs; preserve imported\nsources and do not delete unrelated artifacts to diagnose a version mismatch.\n\n## Migration guides\n\n- **cadgen 0.4 → 0.5** — generator functions became decorated model scripts, the\n  generation CLI was removed, sidecars and provenance moved, snapshot job JSON\n  was re-keyed, and mesh tolerance became relative.\n  https://github.com/earthtojake/text-to-cad/blob/main/docs/migrations/migrating-0.4-to-0.5.md\n","isInternal":false,"tokens":1020,"sizeBytes":4474},{"name":"positioning.md","path":"skills/cad/references/positioning.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/positioning.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Assembly positioning and mating\n\nRead this file when placing assembly parts, defining mating datums or checking\nalignment. Author placements in the model source, then check the saved geometry.\nChoose explicit transforms or native build123d joints according to which makes\nthe intended relationship easiest to express and maintain. Neither requires a\nparticular assembly size or complexity.\n\n## Transforms and local frames\n\nUse functional datums and explicit dimensions for placements. A part's origin\nmight be its mounting interface, symmetry center or rotational axis; use the\nconvention that fits the design. Record it when another part depends on it.\n\n```python\n# Inside an assembly model:\nleft = bd.Pos(-pitch / 2, 0, height) * spacer()\nleft.label = \"spacer_left\"\nright = bd.Pos(pitch / 2, 0, height) * spacer()\nright.label = \"spacer_right\"\nassembly = bd.Compound(children=[left, right], label=\"spacer_pair\")\n```\n\nPlace child models with `.moved()` or `Pos/Rot/Location * child` to preserve\nshared geometry. `.located()` replaces the existing placement and copies the\ngeometry; it loses the cached child link. Labels identify roles and repeated\noccurrences, such as `m3_screw:front_left`. A functional group can be a nested\nlabeled `Compound` when the design benefits from that hierarchy.\n\nMirroring changes geometry, so an inline mirrored child belongs to the parent\nrather than linking to the original child's tree. It remains cached with that\nparent. A separate mirrored model is useful for independent outputs or reuse,\nnot a requirement; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\n\n## Native joints\n\nJoints can express a relationship between reusable datums without hand-solving\nits placement. They perform source-level placement; they are not persistent\nSTEP constraints or a replacement for the separate [kinematics](kinematics.md)\ninterface. Call `connect_to()` on the fixed joint with the moving joint as its\nargument.\n\nFor example, `src/enclosure.py` places a lid above a base with a specified gap:\n\n```python\nfrom cadgen import build123d as bd, step\n\nBASE_HEIGHT = 30.0\nLID_THICKNESS = 3.0\nGASKET_GAP = 0.5\n\n\n@step(out=\"../STEP/enclosure.step\")\ndef enclosure():\n    base = bd.Box(80, 50, BASE_HEIGHT)\n    lid = bd.Box(80, 50, LID_THICKNESS)\n    base.label, lid.label = \"base\", \"lid\"\n    bd.RigidJoint(\n        \"lid_target\", to_part=base,\n        joint_location=bd.Location((0, 0, BASE_HEIGHT / 2 + GASKET_GAP)),\n    )\n    bd.RigidJoint(\n        \"underside\", to_part=lid,\n        joint_location=bd.Location((0, 0, -LID_THICKNESS / 2)),\n    )\n    base.joints[\"lid_target\"].connect_to(lid.joints[\"underside\"])\n    return bd.Compound(children=[base, lid], label=\"enclosure\")\n\n\nif __name__ == \"__main__\":\n    enclosure()\n```\n\nNative joint options include `RigidJoint` for fixed placement, `RevoluteJoint`\nfor rotation, `LinearJoint` for translation, `CylindricalJoint` for combined\nrotation/translation and `BallJoint` for spherical orientation. Use `Location`\nfor rigid/ball joint frames and `Axis` for revolute/linear/cylindrical joints.\n\nCreating joints reads the child's placement and may materialize it earlier\nthan a deferred transform. Cached child models return geometry, labels,\nappearance and placements; do not rely on Python joint objects surviving a\nchild's cache round trip. Define the joints needed by the parent in its source.\n\n## Child models and imported components\n\nCall a project model to compose it. Use `cadgen.read_step` for a vendor document\nor an explicitly decoupled export; like every file a build reads, it is an input.\nSee the [model contract](step-generation.md) for dependency tracking.\n\nFor imported geometry, inspect its existing origin, orientation and functional\nfeatures with `read_scene` before choosing datums. Express measured offsets\nor joint frames in the source, then validate the placed result. Anchor model\ninput paths on `__file__` when they must work from any working directory.\n\n```bash\npython path/to/assembly.py\npython tmp/check_assembly.py\n```\n\n## Alignment and measurement checks\n\nSelect the actual mating features from `read_scene`, then express the intended\nrelationship in Python. There is no generic alignment mode that guesses which\npoints or axes the design means. See `inspection-and-validation.md` for the\nreader and native measurement interfaces.\n\nFor example, for two planar mating faces (with their refs already identified):\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"STEP/assembly.step\")\n(moving_face, fixed_face) = scene.resolve(\"#moving.f1,fixed.f2\")\nmoving, fixed = moving_face.shape(), fixed_face.shape()\nn = fixed.normal_at().normalized()\nm = moving.normal_at().normalized()\nsigned_gap_mm = (moving.center() - fixed.center()).dot(n)\nparallel_error_deg = min(n.get_angle(m), n.get_angle(-m))\nprint(signed_gap_mm, parallel_error_deg)\nassert abs(signed_gap_mm) < 0.01\nassert parallel_error_deg < 0.1\n```\n\nThese thresholds are illustrative; use the task's tolerances. The plane gap\nand parallelism test does not establish lateral alignment or overlapping face\nfootprints. Check those when required. For opposed normals, assert the signed\norientation explicitly instead of accepting either parallel direction.\n\nFor a screw-pattern dimension, compare the analytic centers of the selected\ncircular edges. For clear space between bodies use `closest_points`.\n`shape.bounding_box().size` measures the world-aligned envelope. Use explicit\nvectors and datums for orientation, signed offsets and center spacing.\nUpdate source placements, regenerate, and rerun the relevant checks.\n\nCorrect failed positioning in the model source, then regenerate and rerun the\nrelevant checks. Report measured relationships and any intended alignment left\nunchecked; no positioning report is needed when positioning is irrelevant.\n","isInternal":false,"tokens":1317,"sizeBytes":5845},{"name":"project-layout.md","path":"skills/cad/references/project-layout.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/project-layout.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# CAD project structure\n\nFollow an existing project's layout. For a new multi-model project, use the\nstructure below; a one-off part can use a flat folder. In a larger workspace,\nput CAD work in its existing `models/`, `cad/` or `hardware/` area. In a bare CAD\nworkspace, use the root. cadgen does not infer paths from folder names: each\nmodel declares its outputs.\n\n## Source and output mapping\n\n**One model entrypoint per `.py` file, with matching output stems.** For\nexample, `src/bracket.py` contains one parameterless decorated model and declares\n`STEP/bracket.step`, `STL/bracket.stl`, `GLB/bracket.glb` or `3MF/bracket.3mf`, as\nneeded. Stacking output decorators still declares one model. Helpers and package\n`__init__.py` files are not model entrypoints.\n\n```\n<project>/\n  src/\n    README.md             # model catalog\n    plate.py              # one model per entrypoint\n    plate_drawing.py       # separate drawing model\n    assembly.py           # root assembly\n    chassis/              # optional grouping as the project grows\n      __init__.py\n      frame.py\n      strut.py\n    purchased/            # optional read_step wrapper models for vendor parts\n      servo.py\n    lib/                  # shared helpers and parameterized factories\n      __init__.py\n      holes.py\n  STEP/                   # raw artifacts and generated sidecars\n    plate.step\n    chassis/\n      frame.step\n      strut.step\n    imported/             # external source files\n  DXF/  STL/  GLB/  3MF/  # other requested output formats\n  checks/                 # reusable checks, or the existing test directory\n  tmp/                    # ignored exploratory scripts, snapshots and debug output\n```\n\nKeep code and notes outside the format folders. Mirror nested source paths in\noutput folders so repeated names remain unambiguous:\n`src/chassis/frame.py` → `STEP/chassis/frame.step`. For several mesh densities\nof the same model, retain the stem in separate variant subfolders, such as\n`STL/draft/bracket.stl` and `STL/print/bracket.stl`.\n\nDecorator `out=` paths are relative to the script. For `src/plate.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\nRun `python src/plate.py` from the project root. An unchanged model is a no-op.\nThe CAD Viewer catalogs the saved artifacts; the source catalog identifies what\nto build before those files exist.\n\nPut exploratory inspection scripts in ignored `tmp/` or system `/tmp`, and\nretained checks in `checks/` or the project's test directory. See\n[inspection](inspection-and-validation.md) for examples.\n\n## Imports and shared code\n\nUse ordinary Python imports. Flat scripts can import sibling models and\n`lib/` directly. For nested scripts, declare the import root with `PYTHONPATH`;\ncadgen adds no paths of its own. From the project root:\n\n```bash\nPYTHONPATH=src python src/chassis/frame.py\n```\n\nPython packages and relative imports are supported. With\n`src/chassis/__init__.py`, run the model as a module when it uses relative imports:\n\n```bash\nPYTHONPATH=src python -m chassis.frame\n```\n\nDirect execution puts the script's directory first on the import path. Avoid a\nfilename that shadows a package it imports: `frame/frame.py` can hide the `frame`\npackage when run by path. Use a distinct entrypoint name or module execution.\n\nKeep shared factories, constants and helpers in `src/lib/` or an existing Python\npackage. Subsystems may import shared models or each other where the dependency\nmakes sense; avoid circular imports. Alias a module when importing a same-named\nmodel function would otherwise shadow it.\n\nDependencies are tracked as **models by result, constants by value, functions by reach**:\n\n```python\nfrom lib import fasteners  # helper module: tracked by what the model can run in it\nfrom plate import WIDTH    # literal from a model module: tracked by value\nfrom plate import plate    # model: calling it pins its result\n```\n\n`cadgen store why src/<model>.py` explains freshness. Importing a model never\nbuilds it; calling it inside an assembly does. See the\n[model contract](step-generation.md#models-inside-a-package) for package execution\nand dependency details.\n\n## Assemblies and model boundaries\n\nA parent calls its child models inside its body. The runtime builds stale children\nor reuses their cached results and links them into the parent: **running the root is the\nwhole build**. Run `python src/assembly.py`; rebuilding a child alone\ndoes NOT rebuild the assemblies that use it, so rerun the parent to incorporate\nthe change.\n\nGive a subassembly its own model when it needs independent outputs, reuse or a\nseparate cache boundary. Local construction can remain inline or in an ordinary\nhelper. A helper executes within its caller; a child model has its own record\nand reusable result. Separate folders are useful as the project grows, but a\nsubassembly does not require one. Group related files for navigation without\nforcing the directory tree to reproduce every assembly level.\n\nMirror inline for geometry local to a model; use separate left/right models\nwhen independent outputs or reuse justify them. See\n[mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\nA print-only part can declare `@stl`, `@glb` or `@threemf` without `@step` and\nstill compose into an assembly.\n\nUse factory arguments for configurations, with one entrypoint per independently\nexported model. Geometry must not depend on untracked environment variables,\nworking-directory state, time or random values; the cache cannot detect those\nchanges. The files a model reads are tracked on their own (the\n[model contract](step-generation.md)).\n\n## Naming\n\n- Use an importable Python identifier for the model script's stem, and retain\n  that stem in its declared outputs. Put an external part number in a label or\n  catalog entry if it cannot serve as the shared filename stem.\n- A drawing is a separate model: `plate_drawing.py` → `DXF/plate_drawing.dxf`.\n- Avoid filenames distinguished only by case; many filesystems ignore case.\n- Preserve upstream filenames for imported sources under the format's\n  `imported/` folder. They are inputs, not generated model outputs, and do not\n  need a matching script.\n\n## Renaming or retiring outputs\n\nChanging `out=` or deleting a model does not remove its previous artifacts.\nBefore a rename, record the old output paths, any `.step.json` sidecar and\ndeclared meshes. Rename the entrypoint and its matching outputs, rebuild and\nverify the new files, then remove only the obsolete generated files by exact\npath. Preserve imported sources and unrelated artifacts.\n\n## Building many models\n\nRunning a root builds its dependencies. For independent outputs, run the\ncatalog's entrypoints; independent roots can run concurrently, subject to the\nruntime's CPU and memory admission. Avoid duplicate simultaneous exports of the\nsame document. Batch snapshot views in one JSON job when they share scene settings.\n\nWhen several agents work on an assembly, independent subsystem entrypoints let\nthem build and verify their work before the root incorporates it.\n\n## Model catalog\n\nFor a multi-model project, keep `src/README.md` short and update it when the\nentrypoints or outputs change:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Mounting plate |\n| plate_drawing.py | DXF/plate_drawing.dxf | Plate flat pattern |\n| assembly.py | STEP/assembly.step | Plate with standoffs |\n\nBuild: `python src/assembly.py` from the project root builds the assembly and\nits dependencies. Run independent entrypoints separately. Unchanged models are current.\nImported source: STEP/imported/servo.step.\n```\n\n## Version control\n\nFollow the repository's existing ignore, artifact and Git LFS policies, and the\nuser's commit instructions. For a new project, a useful default is to retain\nauthored code, reusable checks and irreplaceable inputs, while ignoring\nreproducible outputs and scratch files. Keep pinned fixtures or deliverables\nwhen the project requires them; kernel upgrades can change bytes without\nchanging geometry.\n\nExample ignore patterns for that default:\n\n```gitignore\n/STEP/*\n!/STEP/imported/\n/DXF/*\n!/DXF/imported/\n/STL/*\n!/STL/imported/\n/GLB/*\n!/GLB/imported/\n/3MF/*\n!/3MF/imported/\n/tmp/\n__pycache__/\n```\n\nThe `*` forms allow Git to descend into the format folders and honor the\n`imported/` exceptions. Git LFS is optional unless the repository requires it;\nuse it for large binary inputs when appropriate. See the\n[starter](project-template.md#project-bookkeeping) for an LFS example.\n\n## Scaffolding a new project\n\nChoose a [minimal starter](project-template.md): a single part or a small\nassembly. Add other formats, helpers, groups and imported files only as needed.\nBuild the selected entrypoint, check its geometry and review its snapshot.\n","isInternal":false,"tokens":2032,"sizeBytes":8964},{"name":"project-template.md","path":"skills/cad/references/project-template.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/project-template.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Minimal project starters\n\nChoose the starter that fits the task. Create only the files it needs; rename\nparts and change dimensions for the design. Keep an existing project's layout.\nFor a new project, use the location and conventions in [project layout](project-layout.md).\nCommands below run from the CAD project root.\n\n## One part\n\nCreate `src/plate.py`:\n\n```python\n# src/plate.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 60.0\nDEPTH = 40.0\nTHICKNESS = 4.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    body = bd.Box(WIDTH, DEPTH, THICKNESS)\n    body.label = \"plate\"\n    return body\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\n```bash\npython src/plate.py\ncadgen step snapshot STEP/plate.step tmp/plate.png\n```\n\nReview the PNG and check the dimensions needed by the design. A second model\nrun should report `current`. A one-off part can instead use a flat folder and\n`@step` with its default sibling output.\n\n## An assembly\n\nStart with the plate above and add `src/standoff.py`:\n\n```python\n# src/standoff.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nHEIGHT = 12.0\nOUTER_D = 8.0\nBORE_D = 3.4\n\n\n@step(out=\"../STEP/standoff.step\")\ndef standoff():\n    return bd.Cylinder(OUTER_D / 2, HEIGHT) - bd.Cylinder(BORE_D / 2, HEIGHT)\n\n\nif __name__ == \"__main__\":\n    standoff()\n```\n\nThen `src/assembly.py` places two instances on the top of the centered plate:\n\n```python\n# src/assembly.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom plate import THICKNESS, plate\nfrom standoff import HEIGHT, standoff\n\nPITCH = 30.0\n\n\n@step(out=\"../STEP/assembly.step\")\ndef assembly():\n    base = plate()\n    base.label = \"plate\"\n    post = standoff()\n    z = (THICKNESS + HEIGHT) / 2\n    left = bd.Pos(-PITCH / 2, 0, z) * post\n    left.label = \"standoff_left\"\n    right = bd.Pos(PITCH / 2, 0, z) * post\n    right.label = \"standoff_right\"\n    return bd.Compound(children=[base, left, right], label=\"assembly\")\n\n\nif __name__ == \"__main__\":\n    assembly()\n```\n\n```bash\npython src/assembly.py\ncadgen store why src/assembly.py\ncadgen step snapshot STEP/assembly.step tmp/assembly.png\n```\n\nThis is a placement example; add the fastening features the actual design needs.\nRunning the root builds its stale children. After a child changes, rerun the root.\nPlaced children use `.moved()` or `Location * shape` to keep their geometry shared.\nFor mating datums and joint relationships, see [positioning](positioning.md).\n\n## Add capabilities only when needed\n\n| Need | Add |\n| --- | --- |\n| STL, 3MF or GLB output | A mesh decorator; a mesh-only model omits `@step`. See [exports](supported-exports.md). |\n| Shared factory or hole pattern | A plain helper under `src/lib/`, with `src/lib/__init__.py`. |\n| Left/right geometry | Mirror inline, or use separate models for independent outputs/reuse; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models). |\n| Subassembly | A model that calls its child models; use folders as the project grows. |\n| 2D drawing | A separate drawing model using `$dxf`. |\n| Vendor CAD | Preserve the source under the format's `imported/` folder; use `read_step` with an anchored path or a wrapper model. |\n| Durable requirement checks | `checks/` or the project's existing test directory; exploratory checks and images go in ignored `tmp/`. |\n\nOnly kinematics, intrinsic materials or animation require a STEP sidecar.\nDeclaring a mesh alongside STEP does not create one.\n\n## Project bookkeeping\n\nAdd a short `src/README.md` catalog for a multi-model project:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Base plate |\n| standoff.py | STEP/standoff.step | Repeated spacer |\n| assembly.py | STEP/assembly.step | Plate with two spacers |\n\nBuild the assembly with `python src/assembly.py` from the project root.\n```\n\nFollow the repository's version-control policy and the user's commit instructions.\nThe [layout reference](project-layout.md#version-control) suggests defaults and\nignore patterns for new projects. Create output folders and helpers only as needed.\n\nIf the project uses Git LFS for imported sources, scope its `.gitattributes`\nentries to the relevant files or formats, for example:\n\n```gitattributes\nSTEP/imported/** filter=lfs diff=lfs merge=lfs -text\n*.step.json text\n```\n\nConfigure Git LFS before adding files covered by those rules. A file starting with\n`version https://git-lfs...` is a pointer: `git lfs checkout STEP/imported`\nrestores objects already in the local cache; missing objects need fetching from\nthe project's LFS remote before CAD tools can read them.\n","isInternal":false,"tokens":1178,"sizeBytes":4636},{"name":"repair-loop.md","path":"skills/cad/references/repair-loop.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/repair-loop.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Repair loop\n\nUse the failing command and geometry to localize the problem, correct the\nresponsible source or arguments, then rerun the failed and affected checks.\nPreserve specified features and dimensions; disclose any necessary deviation.\n\n## Source and build failures\n\n- **Syntax or import error:** use the traceback to correct the source or\n  dependency. Keep geometry creation inside model functions and their helpers.\n- **Script exits without outputs:** confirm `__main__` calls the decorated model,\n  that it returns a native shape, and that its decorators declare the intended\n  outputs. Function names are not discovered by convention.\n- **Unexpected freshness:** run `cadgen store why <model>.py`; check tracked\n  inputs and whether the parent was rerun after a child changed. See the\n  [model contract](step-generation.md#freshness-cadgen-store-why).\n- **Installation or version mismatch:** run `cadgen doctor <skill-dir>` and\n  follow the specific error. See [migrations](migrations.md).\n\n## Geometry failures\n\n| Symptom | Useful checks and possible remedies |\n| --- | --- |\n| Missing or invalid body | Check profile closure, cut placement, zero thickness and the first failing operation. Use the Python diagnostics in [inspection](inspection-and-validation.md). |\n| Missing hole/pocket | Check feature mode, selector, cut depth and the intended through-condition against the saved geometry. |\n| Wrong scale or extents | Check units, radius versus diameter, primitive alignment and extrusion direction; measure the relevant dimensions. |\n| Fillet/chamfer failure | Check edge selection and local space. Consider feature order or equivalent profile construction. Change a required radius only as an explicit design decision. |\n| Loft failure or distorted surface | Inspect wire correspondence, winding, self-crossings and disconnected sections. Prefixes or adjacent pairs can localize the problem; a successful pair does not prove a valid full loft. |\n| Slow Boolean | Time the suspect operation or inspect a stack sample. Consider simpler surfaces, tool extents, batching or staged cuts while preserving the required feature. |\n| Selector no longer matches | Reopen with `read_scene`, enumerate candidates, and measure their geometry. Numeric refs are revision-scoped. |\n| Assembly placement mismatch | Check local datums, fixed/moving order, axis direction and transform composition. Measure the required signed gaps and angles on saved geometry. |\n\nVersion-specific pitfalls and construction alternatives are in\n[build123d modeling](build123d-modeling.md). For assembly corrections, see\n[positioning](positioning.md).\n\n## Viewer and snapshots\n\nShow the result as in [Show the model](../SKILL.md#show-the-model). With the CLI viewer,\n`cadgen viewer --help` lists its options. If showing fails, report the failure and use\nPython geometry checks plus snapshots.\n\nSnapshot commands take saved documents, not model scripts. Run the model first\nwhen its output is missing; otherwise verify the input format/path, Chromium\ninstallation and the reported options. Missing derived render data is compiled\non demand; adjacent GLB/topology files are not a prerequisite. Try a single\nsupported view when isolating a request error, then restore the views needed\nfor the review. See [snapshot review](snapshot-review.md).\n\n## Check the repair\n\nWhen a repair could affect other geometry, compare before/after documents using\n`read_scene` and the dimensions, volumes or relationships that should remain\ninvariant. Resolve labels independently in each revision. For shape changes,\nnative Boolean differences can show both added and removed material.\n\nReport what failed, what was repaired and verified, and which requirements\nremain untested or unresolved. Identify any artifact that remains usable.\n","isInternal":false,"tokens":756,"sizeBytes":3798},{"name":"snapshot-review.md","path":"skills/cad/references/snapshot-review.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/snapshot-review.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Snapshot review\n\nRead this file when choosing visual checks for saved STEP/STP or mesh outputs.\nUse `cadgen step snapshot` for STEP, or the corresponding `stl`, `3mf` or `glb`\nsnapshot command for meshes (see [mesh exports](supported-exports.md)). The\nCAD topology, selection and motion controls below apply to STEP; clipping applies\nto every 3D input, and exploding to a STEP or a GLB of several parts.\n\n## Policy\n\nEvery created or visibly updated part or assembly gets at least one reviewed\nPNG snapshot of its STEP, or its mesh when no STEP is declared. Passing geometry\nchecks does not waive this visual review. Use the format's snapshot command.\nFor a STEP pose or clip frame, pass `--kinematics` and/or\n`--animation CLIP --time SECONDS`. A motion review may additionally need video;\nsee [kinematics](kinematics.md#reviewing-motion).\n\nSkip saved snapshots only when no visible geometry was created or updated, or no valid artifact exists:\n\n- pure format/export requests where geometry is unchanged\n- source changes that do not alter visible geometry\n- inspection-only tasks (for example direct measurement questions) that create or update nothing\n- failed Python or STEP generation before a valid artifact exists\n\nWhen skipping, report the reason and the deterministic evidence that still ran.\n\nDo not loop on snapshots. Rerender only when a source repair changed visible geometry or when a specific visual finding needs confirmation.\n\n## Packet sizing\n\nChoose views that expose the features being checked. One may be enough;\nadd an opposing view for hidden exterior features, an orthographic view for\na pattern or silhouette, or a clipped view for internal geometry. No fixed set of\nviews proves every face or feature is correct.\n\n## Small packet\n\nFor example, a four-view comparison can use one JSON job:\n\n```json\n{\n  \"input\": \"models/part.step\",\n  \"mode\": \"view\",\n  \"outputs\": [\n    { \"path\": \"/tmp/render/iso.png\", \"camera\": \"iso\" },\n    { \"path\": \"/tmp/render/iso_opposite.png\", \"camera\": { \"direction\": [-1, 1, -0.8] } },\n    { \"path\": \"/tmp/render/top_ortho.png\", \"camera\": \"top\" },\n    { \"path\": \"/tmp/render/front_ortho.png\", \"camera\": \"front\" }\n  ],\n  \"output\": { \"viewLabels\": true, \"padding\": 0.12, \"sizeProfile\": \"diagnostic\" }\n}\n```\n\nUse only the views relevant to the design. Opposed isometric views reveal more\nexterior faces, but neither they nor orthographic views reveal all occluded geometry.\n\nSet `input` to the saved STEP/STP artifact using a relative or absolute path\n(documents only: run a `.py` model first). With no explicit display settings,\nsnapshots use the `solid` preset, Light appearance and an orthographic isometric\ncamera. Every mode defaults to 1600x1200 (the `diagnostic` profile). Job `mode`\nis `view` or `list`; display presets are a separate choice.\n\n`--size-profile` (`output.sizeProfile` in a job) is one of exactly these names;\nanything else is refused with this list:\n\n| Profile | Size |\n| --- | --- |\n| `simple` | 1200x900 |\n| `simple-square` | 1024x1024 |\n| `diagnostic` (default), `labeled` | 1600x1200 |\n| `assembly` | 1800x1200 |\n| `assembly-large` | 1920x1440 |\n| `presentation`, `contact-sheet` | 2400x1600 |\n| `presentation-large` | 2800x1800 |\n\n`--width`/`--height` (an output's `width`/`height` in a job) override the profile\nwith a whole number of pixels from 1 to 8192; a larger request is refused rather\nthan clamped. With `--job` they size every output in the packet.\n\n`--display` takes one of the Viewer's six Display modes, each drawn at its own\ndefaults: `solid` (the default: shaded, with edge linework), `shaded` (the same\nsurfaces, no edges), `parts` (Solid with each part its own colour), `xray`\n(translucent surfaces with occluded edges visible), `hidden-line` (linework with\noccluded edges suppressed), and `wireframe` (edges only); they use the shared\nprojection, orthographic by default. `--grid` draws the measuring grid (red X and\ngreen Y axes) under the model in any mode; it is off by default. `--preview` draws\nPreview's photographic look instead, in perspective with studio lighting, at\nPreview's defaults; it draws no grid and is view mode only.\n\n```bash\ncadgen step snapshot STEP/part.step tmp/review.png --display hidden-line --grid\ncadgen step snapshot STEP/part.step tmp/review.png --preview\ncadgen step snapshot STEP/part.step tmp/review.png --display display.json --camera front\n```\n\n`--display` (and a job's `display`) also takes the Viewer's display object, inline\nor as a JSON file: `mode`, boolean `grid`, `appearance` (`light`, the CLI default,\nor `dark`), `camera` (`projection`: `orthographic` or `perspective`; `focalLength`:\n20–200 mm), and the shared tools `clip` and `exploded`. A mode has no settings of\nits own, and anything else is refused.\n\n`--camera` (or a job's top-level or per-output `camera`) sets pose and framing\n(`preset`, `position`, `target`, `up`, `direction`, `zoom`,\n`orthographicHalfHeight`); projection and focal length belong in\n`display.camera`. Every 3D input takes `clip` (`at` in the file's own unit), and\n`exploded` takes a STEP or a GLB of several parts. `shaded`, `xray`, `hidden-line`\nand `wireframe` are STEP-only; meshes and robot descriptions take `solid` or\n`parts`, `--grid`, or `--preview`. DXF uses `--appearance` and has no scene display\nmodes.\n\n`camera` is a preset (`front`, `back`, `left`, `right`, `top`, `bottom`, `iso`),\nan `azimuth:elevation` pair, or a camera object. `output` takes `sizeProfile`,\n`padding` (0–0.15), `renderScale` (1–3) and the booleans `viewLabels`,\n`tightFrame` and `transparent`. Top-level `timeoutSeconds` is a positive number\nand `scale` is `cad` or `urdf`. Unknown keys and out-of-range values are refused.\n\nFor close macro views in normal CAD, a JSON job can set `quality.tessellation` to\n`{\"chordTolerance\": 0.0005, \"angleTolerance\": 0.10}`. Chord tolerance is\nrelative to each component's bounding diagonal; angle tolerance is radians.\nThese positive numeric overrides retessellate the exact STEP surfaces and use\nseparate shared-cache entries. They do not change the STEP geometry or a model's\ndeclared mesh-export tolerances. Use them only when visible faceting needs finer\nsampling; lower tolerances cost more memory and render time. `chordTolerance`\nmust be at least `0.00001` and `angleTolerance` at least `0.005` — finer than\nthat exhausts the renderer instead of improving the image, and the job is\nrefused.\nExisting mesh documents cannot be retessellated this way. The explicit top-level\nsampling request works in every display mode. When it is omitted, `--preview` draws\nat Preview's final LOD.\n\n\n### Flags and job keys\n\nA JSON job's keys are the flags without their dashes, and the job is the only\nplace some shapes exist. `--animation CLIP --time SECONDS` is ONE request, so a\njob carries it as one `animation` object — `time` is not a top-level job key:\n\n```json\n{\n  \"input\": \"models/arm.step\",\n  \"kinematics\": \"open\",\n  \"animation\": { \"clip\": \"demo\", \"time\": 2.0 },\n  \"outputs\": [{ \"path\": \"/tmp/render/demo_t2.png\", \"camera\": \"iso\" }]\n}\n```\n\n`clip` names a clip embedded in the document sidecar's animation and is required;\n`time` is seconds, finite and >= 0, defaulting to 0. A bare clip name is the\nFLAG's spelling, not the job's: `\"animation\": \"demo\"` is refused, as is any key\nthe job does not support — the error lists the supported set.\n\nA `\"video\"` object beside it renders the clip's SPAN into the `.mp4` or `.gif`\nthe single output names, instead of one frame: `{\"fps\": 30, \"seconds\": <what is\nleft of the clip>, \"start\": 0, \"quality\": \"review\", \"loop\": true}`, every key\noptional and every other key refused. It needs `animation`, refuses an\n`animation.time`, refuses a `start` past the end of the clip, and needs ffmpeg\ninstalled. See `kinematics.md`, \"Rendering the whole clip\".\n\nIn a JSON job these two flags are the one exception to \"job key = flag name without dashes\": they nest under a job-level `selection` object, and a top-level `\"hide\"` or `\"focus\"` is rejected as an unknown key. Selection applies to the whole job, not to one output — to hide or focus parts for a single view, give that view its own job in a `jobs` array.\n\n```json\n{\n  \"input\": \"STEP/assembly.step\",\n  \"mode\": \"view\",\n  \"selection\": { \"hide\": [\"#o1.3\", \"#o1.4\"] },\n  \"outputs\": [{ \"path\": \"tmp/render/without_covers.png\", \"camera\": \"iso\" }]\n}\n```\n\n`\"selection\": { \"focus\": [\"#o1.2\"] }` is the `--focus` form; `focus` and `hide` are the only selection keys. Every other flag keeps the plain rule (`--kinematics` → `\"kinematics\"`, `--animation CLIP --time S` → `\"animation\": {\"clip\": ..., \"time\": ...}`).\n\nWith `--job FILE`, other flags override every job in it; there is no stdin form.\nA TARGET given with `--job` replaces every job's `input`, so one job file renders\nthe same views over another model; OUT is ignored, and the jobs' outputs name\nthe files:\n\n```bash\ncadgen snapshot STEP/bracket_v2.step --job views.job.json\n```\n\n### Annotations and Quick Edit views\n\n`--annotation FILE` (a job's `annotation`: a path or the object) draws a person's ink\nover the model as they drew it in the CAD Viewer: its marks are in model\ncoordinates and stay put whatever explodes or poses the model, and are cut by a clip; what\nthe model hides of them is drawn faint through it. A mark lies on the surface it was\ndrawn on, or on that surface's plane (flat across a curved face, carried on in the air past the\nmodel's edge), so its points need not be coplanar.\nEach mark's `touches` says what it was drawn over, most first: the faces and edges\nas `@cad[...]` references (`ref`) where the Viewer could name them, each with its\npart — read those to know what a mark is about.\nA DXF annotation lies on the drawing plane; `--mode list` takes none.\n\nA Quick Edit prompt carries a snapshot packet captured only when Copy, Queue or\nSend is pressed. Up to 1 KiB of compact UTF-8 JSON is included directly in the\nprompt; larger packets are saved to a temporary `.snapshot.json` and named by\nabsolute path. Save inline JSON to a temporary `.snapshot.json` before calling\n`cadgen snapshot --job <file>`; the CLI takes a file path. Render and inspect supplied\nannotations before editing. A view without annotations is optional context: render it\nonly when useful to the request; its presence alone does not require inspection.\nThe `jobs` list includes the current view and every distinct drawn view with\nremaining ink. A matching drawn view uses the exact current framing in the export;\notherwise `current view` is appended. Text and selections without\nink still capture the current view. Shared `input` and inline `annotation`\nlive at the top level once. An individual job can override any\nshared field (whole-value replacement, not a merge); empty `marks`\nsuppress shared marks. Captured 3D views preserve projection and lens. No PNGs are\nrendered or attached at handoff. Input and output paths are absolute, so the packet\ncan be replayed from another working folder. Annotation chips do not\nexport individual references; their data travels in the snapshot packet. A Quick Edit from any 3D file replays: a robot's job carries its\n`jointValues` and the parts hidden in its Links panel (`selection.hide`), a GLB's or a\n3MF's its `display.clip`, and a multi-part GLB's its `display.exploded`. `cadgen snapshot --job <job\nfile>` renders those views again: after an edit, run it to see the change from\nwhere the person looked, with their annotation still on it. Add a TARGET to aim the\nsame views at another document.\n\n## Clipped views\n\nUse the viewer's Clip setting to reveal internal geometry in a normal view snapshot.\n`display.clip` takes `enabled`, `axis` (`x`, `y` or `z`), `at` in the file's own unit\n(a STEP's millimetres; a GLB's, URDF's and SDF's metres; a 3MF's declared unit), and\n`invert`. The cut stays capped and uses the same scene as the viewer.\n\n```bash\ncadgen step snapshot STEP/housing.step tmp/cut.png --camera back --display '{\"clip\":{\"enabled\":true,\"axis\":\"y\",\"at\":12.5}}'\n```\n\nOUT's extension picks the format: a view writes `.png`, and a video writes\n`.mp4` or `.gif`. Other extensions are refused instead of writing PNG bytes under them.\n\n## Output paths\n\nName the file and you get that file:\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\n# then Read tmp/review.png\n```\n\nOUT (and an output's `path` in a JSON packet) is written exactly as given,\nrelative to the working directory. Check the command's exit before reading. A\nrefused request — an unknown key or value, a setting this kind of input cannot\ntake, the wrong door, conflicting options, an unknown pose, clip or joint name —\nleaves an existing file untouched, for every job in a packet. Once the request is\naccepted the target is cleared before anything is built, so a failed build or\nrender (and an occurrence ref the model does not have, which needs the built\ntree to check) leaves no file; successful output is written atomically.\nReuse `tmp/review.png` during iteration, or name before/after images when both\nare needed for comparison.\n\nPass a directory (`tmp/` as OUT, or an output `path` that is one) only when the name does not matter: a timestamped name is generated inside it, and that is the one case where you read the path from the `saved snapshot:` line.\n\n## Targeted additions\n\nAdd views only when the brief or a failure mode calls for them:\n\n- reference-image reproduction: one snapshot from the reference image's viewpoint for side-by-side comparison\n- `display.clip` with a camera facing the cut: shell, bore, internal cavity, passage, blind hole, enclosure, or wall/floor relationship (see [Clipped views](#clipped-views))\n- `display.mode: \"solid\"`: shaded CAD view with visible edge linework\n- `display.mode: \"shaded\"`: shaded surfaces without the edge overlay\n- `display.mode: \"xray\"`: translucent surfaces with hidden/occluded edges visible\n- `display.mode: \"hidden-line\"`: line-focused review with occluded edges suppressed\n- `display.mode: \"wireframe\"`: edge-only review for internal overlap or interference\n- `display.grid: true`: a measuring grid under the model in any display mode, for reading size and placement at a glance\n- labeled or annotated review: use supported CAD Viewer refs, selections, screenshots, or GUI review links\n\nExploded or labeled review is an intent, not a render mode. Satisfy it through supported CAD Viewer mechanisms, supported JSON job settings, or the GUI link.\n\n## Diagnostic review\n\nVisual review is diagnostic, not authoritative. Convert every visual concern into a follow-up geometry check before using it as a validation claim:\n\n- hole pattern appears asymmetric -> measure hole centers and compare offsets\n- lid, child part, or occurrence appears offset -> inspect frames and mating deltas\n- gusset, boss, standoff, rib, or plate may be floating -> inspect solid count, labels, connectivity, contact, or relevant distances\n- cavity, bore, or blind hole looks wrong -> review a clipped view, then measure wall thickness, depth, or through-condition\n- repeated pattern looks uneven -> measure pattern centers, angular spacing, or occurrence frames\n\nFinal reports name what the snapshots showed, or the documented skip reason, and which deterministic checks support any visual finding. The PNGs are for your review; the user sees the model in the viewer, so attach one only when they ask for an image.\n","isInternal":false,"tokens":3816,"sizeBytes":15334},{"name":"step-generation.md","path":"skills/cad/references/step-generation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/step-generation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# The model contract and STEP generation\n\nRead this file when authoring or rebuilding a model script, composing models\ninto assemblies, deciding what a rebuild tracks, or working with imported\nSTEP/STP files.\n\n## The model script is the tool\n\nGeneration has no CLI. A model is a plain Python script whose `__main__` calls\nthe decorated function; that call builds it:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step\ndef bracket():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython bracket.py                 # builds bracket.step (and the result tree in the store)\npython bracket.py --force --json  # per-run flags ride the script's argv\n```\n\nEvery run updates the cached native geometry and writes the outputs declared\nby the model. Display data is derived on demand. Unchanged sources are a\nfast no-op. The default `.step` is the sibling `<stem>.step`; relocate it\ndurably with `@step(out=\"path/to/out.step\")` (relative to the script). There\nis no per-run output override: a model has one set of outputs, declared in its\ndecorators, and the store's record of it is keyed by the script.\n\nRules the decorator enforces:\n\n- **The decorator only declares.** Nothing runs at decoration or import time.\n  To build by running the file directly, end it with\n  `if __name__ == \"__main__\": <model>()`. Imported models build when called.\n- **A top-level call builds.** Calling the decorated name when no build is in\n  progress (`__main__`, a REPL) runs the pipeline; a failed build exits with\n  the pipeline's code. A conventional real-file `__main__` bare call such as\n  `plate()` finishes every output without loading its discarded geometry back\n  into that process. It takes no arguments.\n- **A call inside a build composes.** From another model's body the same name\n  returns the shape: the child is built if it is stale (writing ITS outputs\n  and record), otherwise loaded from the store, and either way its result is\n  linked into the parent's. Composition is ordinary Python; there is nothing\n  to cache by hand and no composition API.\n- **Use one decorated model per entrypoint file.** Match the script's stem in\n  its declared outputs: `plate.py` → `STEP/plate.step`, `STL/plate.stl`, etc.\n  Stack output decorators on that one function; put shared factories in helper\n  modules and independently exported configurations in separate entrypoints.\n  The model is addressed by its script path (`python plate.py`,\n  `cadgen store why plate.py`). This is the skill's project convention;\n  helpers and package `__init__.py` files are not model entrypoints.\n- **Calling a model from plain Python returns its geometry.** Outside a build,\n  `plate()` builds (or finds current) and returns the model's tree as a\n  `Compound` — what a parent composing it would get — so a script, a notebook\n  or a REPL can read bounds, faces or volumes straight off a model. Assign or\n  otherwise consume the call when using that return. Only a proven discarded\n  real-file `__main__` bare call skips this materialization; instrumented,\n  interactive and ambiguous calls keep returning geometry. A drawing returns\n  `None`.\n- **A model takes no parameters.** It is one configuration of one set of\n  outputs, so there is nothing for an argument to select; the decorator\n  refuses a parameter list. Parametric geometry is a plain factory the model\n  calls:\n\n  ```python\n  from __future__ import annotations   # keeps `-> bd.Shape` a string, not an import\n\n  from cadgen import build123d as bd\n  from cadgen import step\n\n\n  def _bracket(width: float, thickness: float) -> bd.Shape:\n      return bd.Box(width, 10, thickness)\n\n\n  @step\n  def bracket():\n      return _bracket(width=40.0, thickness=6.0)\n\n\n  if __name__ == \"__main__\":\n      bracket()\n  ```\n\n  A second configuration is a second model (`bracket_wide.py`), with its own\n  outputs — the way two part numbers are two parts. Values a model shares with\n  its drawing or its assembly live in module constants (`WIDTH = 40.0`) that\n  the siblings import.\n- **The return is a bare build123d `Shape` and nothing else** — a dict return\n  is refused. The return IS the geometry: a `Compound` placing children is\n  packaged as occurrences (linked where a child is another model's result), a\n  single solid as one component. `read_scene` exposes that saved occurrence\n  hierarchy; manufacturing intent is not inferred from it.\n- **Outputs are what the decorators declare.** `@step` writes the `.step`.\n  Mesh outputs are `@stl`/`@threemf`/`@glb` stacked on the model, tolerances\n  on the decorators (`supported-exports.md`). **A model may declare no STEP at\n  all**: `@stl`/`@threemf`/`@glb` with no `@step` is a full model — same tree,\n  record, build, no-op and composition — that writes its meshes and no\n  `.step`, no sidecar. STEP is one output kind, not a requirement.\n- Options on `@step`: `out=`, `mesh_tolerance=`, `mesh_angular_tolerance=`,\n  `kinematics=`, `materials=`, and `animation=` (`kinematics.md`). **No decorator argument changes the\n  geometry a model produces**: they decide where the files land, how they are\n  written, and what the sidecar declares. `animation=` is a self-contained\n  JavaScript ES module string embedded in that sidecar. `materials=` is\n  `{\"definitions\": {id: material}, \"assignments\": [{\"targets\": [\"#label\", \"#group\"], \"material\": id}]}`;\n  definitions remain available even when unassigned, and every target must\n  resolve exactly. See `build123d-modeling.md` for the material channels.\n  Everything a model declares about itself lives in its decorators, and a\n  child's intrinsic materials inherit through its pinned tree. Kinematics and\n  animation are document-scoped and never inherit into a parent.\n\n**Imports:** use `from cadgen import build123d as bd`, a lazy re-export,\nso a current model can finish before loading the CAD kernel. Keep geometry,\nCAD file reads and `bd` attributes out of module-level constants/defaults.\nUse `from __future__ import annotations` for annotations such as `-> bd.Shape`.\nRaw `import build123d` works but pays the import cost on every rerun.\n\n**A model runs like `python script.py`.** Its folder is on `sys.path` for the\nwhole build, plus your `PYTHONPATH` — cadgen adds nothing else and infers no\nproject root — so an import inside the body, or inside a helper the body calls,\nresolves exactly like one at module top — and the file it loads is hashed when\nit executes, so it is in the closure either way. Prefer module-top imports for\nreadability and so the static scan sees the graph up front; a lazy import is\nnot an error.\n\n## Generated vs imported STEP\n\nThese two terms classify a STEP file by what its source is:\n\n- A **generated STEP file** has a model script as its source. The STEP is a\n  *derived output*; the script is what you edit and re-run.\n- An **imported STEP file** is its own source: authored or downloaded\n  elsewhere. There is nothing upstream to regenerate.\n\nA STEP model with `kinematics=`, `materials=`, or `animation=` writes a sidecar\nbeside its output (`<name>.step.json`) containing the corresponding resolved\nsections, bound to the saved STEP's byte hash. A model with none writes no\nsidecar; its store record makes reruns no-op. Mesh declarations stay in that record.\nCompiling an imported STEP preserves any authored sidecar. The written STEP file\nitself carries NO cadgen metadata and no link back to source code, ever — a\nbare artifact copied anywhere is a plain importable file, and every door\nresolves it by its bytes, so a moved or copied document renders identically to\nits twin.\n\n## Composing on other parts: children and inputs\n\nA model that builds on another part wires it in one of two modes. Choose\ndeliberately:\n\n- **A CHILD (the default)** — the other part is a model in this project:\n  import its function and call it. A child edit flows into the parent on the\n  parent's next rebuild; there are no exported bytes to keep in sync. Never\n  route a generated child through its exported `.step`.\n- **An INPUT** — the other part is a document, not source: a purchased or\n  downloaded part, or a generated part the user has EXPLICITLY asked to\n  decouple (export it once, then treat the export like any other document).\n  Read it with `cadgen.read_step`, below.\n\n### Children\n\nA child is just an import: model scripts are real modules, and\n`from widget import widget` binds the model with no build side effects.\nCalling `widget()` inside the parent's body builds the child when it is stale\n(writing the child's own outputs) or loads its result from the store, and\nreturns the shape. What comes back is GEOMETRY only — tree, labels, colors,\nplacements. A child's sidecar content (its mates, kinematics, animation) never\nrides up into the parent: declare what the assembly needs on the assembly.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom link_pin import link_pin   # importing binds; never builds\n\n\n@step(out=\"../STEP/link_arm.step\")\ndef link_arm():\n    bar = bd.Box(40.0, 8.0, 4.0)\n    bar.label = \"bar\"\n    pin = link_pin()                                   # built if stale, else loaded\n    left = pin.moved(bd.Location((-15.0, 0.0, 2.0)))   # placed: the parent LINKS to the pin\n    left.label = \"pin_left\"\n    right = pin.moved(bd.Location((15.0, 0.0, 2.0)))   # placed again: a second link, one tree\n    right.label = \"pin_right\"\n    return bd.Compound(children=[bar, left, right], label=\"link_arm\")\n\n\nif __name__ == \"__main__\":\n    link_arm()\n```\n\n**Link or component.** Place a child's shape as it came back — `moved()`,\n`Pos/Rot/Location * child`, relabelled, recolored — and the parent's result\nLINKS to the child's tree (stored once, shared by every parent; two placements\nare two links to one tree). Modify it (a boolean, a mirror, extracting a\nsub-shape) and the parent owns that geometry as its own components; the\ndependency is tracked either way. **Never `located()`** for placement: it\ndeep-copies the geometry, which makes it the parent's own component instead\nof a link (`positioning.md`). Put geometry changes that belong to the child in\nthe child's file or its factory.\n\nChild calls submit work and return lazy shapes. Placements, labels and colours\ncan be set before geometry is ready; geometry queries wait for the child.\nIndependent children can build in parallel, subject to runtime admission.\nNo scheduling code is needed in the model.\n\n**Dependency is pull.** A parent depends on each child by RESULT: its record\npins the child's tree hash, so a child edit that yields identical geometry\nleaves the parent current, and an edit that does not reach a child skips that\nchild's Python and kernel work entirely. **Rebuilding a child does not rebuild\nthe assemblies that use it** — run the parent to pick up the change\n(`python src/robot.py` builds whatever is stale beneath it and links the\nrest). A parent finished against a child that changed during its build says so\n(`already stale: … rerun`).\n\n### What a rebuild tracks — models by result, constants by value, functions by reach\n\nWhat an importer TAKES from a model file decides how that file counts:\n\n- **`from widget import widget`** (the model function), called → tracked by\n  RESULT: the parent pins the child's tree, so an edit inside `widget()`'s body\n  rebuilds the parent only when the child's geometry changes. Importing\n  `widget.py` still runs its top level in the parent's process — its imports,\n  module-level code and any decorator argument that is not a literal — so an\n  edit there rebuilds the parent too (a literal such as `out=\"...\"` does not).\n  Keep a model file's top level to imports, constants and definitions.\n- **`from widget import WIDTH`** (a module-level literal: a number, string,\n  bool, `None`, or tuples/lists/dicts of those) → tracked by VALUE: a\n  comment or body edit in `widget.py` leaves the importer current; only a\n  changed value rebuilds it. A value computed by module-level code is\n  import-time code: editing that expression rebuilds the importer too.\n- **Anything else** from a model file (a helper function, a `bd.` object, an\n  expression) → tracked by FILE: the whole file joins the importer's source\n  closure, and any edit to it rebuilds the importer. Shared helpers therefore\n  belong in `lib/` (a plain module, in the closure of every model that\n  reaches it — by the functions and constants the model can actually run, so\n  editing a helper no model calls rebuilds nothing), and shared constants may\n  live in a model file or in `lib/`.\n\nInputs join the closure too: every data file the build opens is hashed as a\nbuild input, whatever opens it (below). A new\nfile that changes what an import finds — an `__init__.py` added to a folder, a\npackage beside a module, a same-named module earlier on the path — also makes\nthe model stale. Embedded `animation=` source and named\n`materials=` are decorator annotations. Imported values and helper calls\nremain ordinary source dependencies.\n\nEvery decorator argument is ordinary Python, evaluated when the module is\nimported: `out=f\"{FOLDER}/{NAME}.step\"`, `mesh_tolerance=TOL` with `TOL` from\n`lib/`, a path built from a constant — all fine, and nothing is read off the\nsource text. The values feeding them are tracked like any other input (a\n`lib/` helper by reach, a model-file constant by value), so changing the\nconstant behind an `out=` makes the model stale. The module top must still stay\nkernel-free so checking the model's declarations stays cheap.\n\n### Splitting for rebuild speed\n\ncadgen never caches work inside a model: a model whose inputs changed runs\nfrom scratch, and one whose inputs did not is skipped whole. Rebuild speed\ntherefore comes from how the project is split:\n\n- Give a part its own model when it dominates its model's build time, takes\n  more than about 15 s, or is edited independently of its neighbours: a thin\n  entry file whose `@step` function calls a factory in `lib/`, placed by its\n  parent like any child. An edit then rebuilds that part and relinks the\n  parent; its siblings stay current, and stale children build in parallel. One\n  heavy casing or shell inside a model of many light parts is the case that\n  pays most: edits to the light parts stop paying for it.\n- Keep geometry in `lib/` factories. A model reruns only when code it reaches\n  changes, so an edit to one factory leaves the models that never call it\n  current.\n- One entry file per expensive model: every `@step` function in a file is\n  stale when any line of that file changes.\n\nA part that builds in a second or two gains nothing from its own file; check\ntimings with `--verbose` before splitting further.\n\n### Annotation caching\n\nAnnotation-only edits may reuse cached geometry; computed or imported\nannotations remain tracked dependencies and may require a rebuild.\n\n### Models inside a package\n\nA model file may live inside a Python package (folders with `__init__.py`).\ncadgen runs it under its dotted name, so relative imports (`from .parts.washer\nimport washer`) resolve whenever cadgen loads the model: as a child of another\nmodel, or when you run it as a module (`python -m pkg.stack`). Running the file\nby path (`python pkg/stack.py`) is Python's own limit, not cadgen's: Python\nexecutes it as `__main__` with no package, so a relative import fails before\ncadgen is involved; use `-m` or absolute imports for a file you run directly.\n`PYTHONPATH` still declares any import root beyond the script's own folder;\ncadgen adds nothing of its own.\n\n### Mirrored geometry and reusable models\n\nMirror geometry inline when it belongs to the current model. For example,\n`right = bd.mirror(left, about=bd.Plane.YZ)` needs no separate model file.\nIn cadgen, reflection creates geometry rather than a rigid placement of the\noriginal child: a mirrored child becomes parent-owned components, while its\nsource model remains a tracked dependency. The parent's complete result is\nstill cached, and an unchanged parent remains a no-op.\n\nUse a separate model when the mirrored part needs independent outputs or reuse\nacross assemblies or parent rebuilds. Its result can then be linked and placed\nlike any other child. A separate model is also the unit of reuse: cadgen\nnever caches work inside a model, so an unchanged model is skipped whole and\na changed one runs whole (see [Splitting for rebuild speed](#splitting-for-rebuild-speed)).\n\nFor independently exported left/right variants, a shared factory is one option:\n\n```python\n# src/lib/bracket_shape.py — the factory (plain module, no decorator)\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\n\n\ndef side_bracket(mirrored: bool = False) -> bd.Shape:\n    body = bd.Box(40.0, 10.0, 6.0) - bd.Pos(12.0, 0.0, 0.0) * bd.Cylinder(2.5, 6.0)\n    return bd.mirror(body, about=bd.Plane.YZ) if mirrored else body\n```\n\n```python\n# src/bracket_left.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_left.step\")\ndef bracket_left():\n    return side_bracket()\n\n\nif __name__ == \"__main__\":\n    bracket_left()\n```\n\n```python\n# src/bracket_right.py\nfrom cadgen import step\n\nfrom lib.bracket_shape import side_bracket\n\n\n@step(out=\"../STEP/bracket_right.step\")\ndef bracket_right():\n    return side_bracket(mirrored=True)\n\n\nif __name__ == \"__main__\":\n    bracket_right()\n```\n\nChoose between inline geometry and separate models from the outputs and reuse\nthe project needs. Either can participate in the containing assembly's kinematics.\n\n### Inputs: reading a STEP file the model does not generate\n\nPrefer `cadgen.read_step` to `build123d.import_step`: it returns the same\nnative shape with the document's colours, and reads warm from the store when it\nalready holds the file's tree. Either way the file is a build input: replacing\nthe vendor STEP makes the model stale on its own, with no `--force`.\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step\ndef rig():\n    motor = read_step(_HERE / \"imported\" / \"vendor_motor.step\")   # recorded input\n    ...\n```\n\nAn imported part is an INPUT, not a model: nothing links to it and it has no\nrecord. To make it first-class — so assemblies link to it, so it has its own\noutputs and declarations — wrap it in a model of its own:\n\n```python\nfrom pathlib import Path\n\nfrom cadgen import read_step, step\n\n_HERE = Path(__file__).resolve().parent\n\n\n@step(out=\"../STEP/servo.step\")\ndef servo():\n    return read_step(_HERE / \"..\" / \"STEP\" / \"imported\" / \"sg90_servo.step\")\n\n\nif __name__ == \"__main__\":\n    servo()\n```\n\n**Never `read_step` your own output.** A model that reads the `.step` it is\nabout to write is not a loop — it is a model whose input changes every time it\nruns, so the gate can never say \"current\", every build is a full rebuild, and\nthe geometry depends on what the last run happened to leave on disk. Keep\nsource documents where the model cannot write them — placement policy belongs\nto `project-layout.md` (`imported/`). Input path and output path being different\nfiles is the whole rule. If the geometry you want is something the project\nalready builds, call that model instead of reading the artifact.\n\n### Inputs: a data file the model reads\n\nEvery file the build opens is recorded as it reads it, whatever reads it: a\nJSON routing atlas through `json.load`, a CSV of tap sizes, a table through\n`np.load`, a BREP `bd.import_brep` opens in C++, a project font `bd.Text` loads. Edit\n`atlas.json` and the model is stale on its own; rewrite it with identical bytes\nand it stays current, because the input is the content and not the mtime. A\nfolder the model globs is recorded too, so adding or removing a profile there\nrebuilds it. The model's own outputs in that folder do not count; another\nmodel's do, so a model that globs the folder its siblings build into reads stale\nonce after their first build. Nothing is declared.\n\nNot inputs: a file the build writes, the model's own outputs, the Python\nenvironment, and system files (fonts, time zones). A program the model starts\nas a separate process reads files on its own, unseen: keep that step outside\nthe model and read its result.\n\nFor structuring multi-part projects (folder layout, shared `src/lib/` code,\ncommit policy), read `project-layout.md` and `project-template.md`.\n\n## Freshness: `cadgen store why`\n\n`cadgen store why <model>.py` explains whether the model is current and which\nsources, imported constants, child results, cached objects or declared outputs\nchanged. It also accepts a generated STEP when the store knows its source.\nExit status is 0 for current, 1 for stale; `--json` returns the verdict as data.\n\n```bash\ncadgen store why src/frame.py\npython src/frame.py                 # rebuild the parent after a child changes\npython src/frame.py --force         # force this model's body to run\n```\n\nThe gate cannot track geometry selected by environment variables, the working\ndirectory, time or randomness. Put configurations in source/factory arguments;\nthe files a model reads are tracked on their own. After a runtime fix, force affected models\nif their cached results still reflect the old behavior.\n\n## Generated assemblies\n\nAn assembly is a model whose return places children (a `Compound` of parts\nor of other models' results); `read_scene` exposes the saved hierarchy.\nEdit the `.py` source to change an assembly; inspect or export its saved STEP\nto check what was actually written. Use native labels and choose transforms\nor native joints to express placement; see [positioning](positioning.md).\n\n## Imported STEP/STP files\n\nAn imported STEP/STP file needs no model script and no preparation step. Hand\nit straight to `read_scene`, `cadgen step snapshot`, or a mesh door:\neach compiles a tree from the file's bytes on first use (a job in the pool,\nshared with the CAD Viewer), and its part/assembly kind is inferred from the\nSTEP product hierarchy.\n\n```bash\npython tmp/check_imported.py  # read_scene(\"path/to/imported.step\")\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nTo produce STL/3MF/native GLB files from an imported STEP, pass it to the\nmatching format command. OUT is optional and defaults to a sibling with the\nrequested extension; read [mesh exports](supported-exports.md).\n\n### Re-emitting a foreign STEP as your own\n\nA STEP written by another kernel round-trips through cadgen with\n`cadgen step build IN OUT`: OCCT reads it, the tree is built, and the\ncanonical writer emits it, so OUT's bytes are deterministic and identical on\nevery run. The same command ANNOTATES a document that has no model script —\n`--kinematics` takes the whole space (`{mates, couplings, poses}`, the same\nvocabulary the decorator takes, as inline JSON or a `.json` path);\n`--materials` takes the named declaration as inline JSON or a `.json` path;\nand `--animation` takes a JavaScript module file or its source text. All\nthree resolve into OUT's unified schema-9 sidecar.\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\nRe-running is a no-op; editing only these annotations refreshes the sidecar\nwithout re-emitting a byte. Vendor metadata (PMI, GD&T) does not survive the round trip.\nOUT's root is named after OUT: the root `PRODUCT` and the header's `FILE_NAME`\ntake OUT's file stem (`STEP/hinge.step` → `hinge`), exactly as a model script's\ndocument is named after the file it writes — so the same input re-emitted to two\npaths yields two differently named documents. Names below the root are kept, so\n`#label` references to the parts keep resolving; do not address the root by the\nname IN carried.\n**Choose the door by how the model will evolve**: a shape you will keep changing\nbelongs in a model script (a thin wrapper that reads the foreign STEP), while\na one-shot canonicalization or annotation of a file you do not own is exactly\nwhat `step build` is for.\n\n## Optional-module assemblies\n\nA model that imports several part modules and SKIPS the ones that do not exist\nyet is a useful pattern for parallel work — the assembly stays renderable while\nindividual parts are still being written. It has one sharp edge.\n\nThe model's closure is computed from the modules it ACTUALLY IMPORTED at build\ntime. A module that did not exist during the build was never in the closure,\nso its later appearance cannot make the model stale, and every door keeps\nreading the old document's tree — no error, no warning. Run the model script\nwith `--force` after adding a previously absent part module.\n\n## After generation\n\n- Confirm the process succeeded and each declared output exists and is\n  non-empty (the stdout line names the document; `--json` adds the `tree`\n  hash).\n- Run the Python geometry checks selected for this design per\n  `inspection-and-validation.md`:\n\n```bash\npython tmp/check_model.py\n```\n\n## Progress and runtime diagnostics\n\nThe warm daemon is on by default. `cadgen daemon status` shows running and\nqueued work. Jobs may wait for CPU or memory admission; concurrency and worker\nlifetimes are runtime details, specified in the installed package's `STORE.md`.\nFor diagnosis, `CADGEN_DAEMON=0 python part.py` uses transient workers with the\nsame geometry/output contract.\n\nResults go to stdout; progress and errors go to stderr. Model runs accept\n`--json` for machine-readable results and `--verbose` for timings/full tracebacks.\nA `--json` result names its `document` by absolute path, and a `--json` failure\nis one `{\"ok\": false, \"error\": \"...\"}` line on stdout with exit status 1 — the\nsame envelope every `cadgen` command prints. `python part.py --help` lists the\nper-run flags. A script builds the models its `__main__` block calls; no flag\nselects one.\nFor long model bodies, optional `report` and `track` calls expose progress:\n\n```python\nfrom cadgen import report, track\n\n# Inside a model body:\nreport(\"ribs\")\nfor rib in track(ribs, label=lambda r: r.name):\n    ...\n```\n\nUse one cache store for normal project work. A separate `CADGEN_CACHE_DIR`\nis useful for isolated tests, but two stores writing the same project outputs\ncan cause repeated freshness misses. The store holds derived results only.\nPrefer a targeted `--force` or `cadgen store forget <model>.py` when diagnosing\none stale result; `cadgen store gc` removes unreachable cache data. The store\nkeeps itself under `CADGEN_STORE_MAX` (default 20 GB) by evicting its least\nrecently written derived entries; `cadgen store info` shows its size.\n","isInternal":false,"tokens":6380,"sizeBytes":26727},{"name":"supported-exports.md","path":"skills/cad/references/supported-exports.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/references/supported-exports.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Supported exports\n\nRead this file when the user requests STL, 3MF, or native GLB output files from CAD geometry. For a `.step` file, run the model script (see `step-generation.md`) — a mesh door writes mesh formats only. For 2D DXF output, use the `$dxf` skill: a drawing is its own `<name>.py` declaring one `@dxf` function — one model per file, so a drawing never shares a script with a `@step` model.\n\n## Policy\n\nValidate the requested geometry and outputs. When a STEP is declared, inspect\nthat saved document; for a mesh-only model, check the returned native geometry\nand review its mesh. A mesh image does not establish exact dimensions or topology.\n\nNative GLB exports are ordinary glTF 2.0 binary files for external tools: Y-up, with one material per distinct part/face color. Do not confuse them with what the CAD Viewer renders from — the model's result tree in the store (`~/.cache/cadgen`: content-addressed exact-geometry components plus links to child trees), which every build writes and a mesh door never does.\n\n## Declare the exports the model always has\n\nStack `@stl`, `@glb` or `@threemf` on the model for maintained mesh outputs.\nFor example, a project-root `bracket.py` can declare:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import glb, step, stl\n\n\n@step(out=\"STEP/bracket.step\")\n@stl(out=\"STL/bracket.stl\")\n@glb(out=\"GLB/bracket.glb\")\ndef bracket():\n    return bd.Box(40, 20, 6)\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\nRunning the script writes the STEP and declared meshes, and restores declared\noutputs that were deleted or edited. Declarations live in the model's store\nrecord. Export commands read saved documents and their appearance annotations,\nnever the model's output declarations. Mesh declarations alone create no sidecar.\n\n## A model with no STEP\n\nA model's outputs are whatever its decorators declare, and STEP is one output kind, not the primary. A function decorated with `@stl`, `@glb` or `@threemf` alone — no `@step` — is a full model: the same tree and record in the store, the same build, the same parallel children, the same no-op when nothing changed, the same composition (`spacer()` inside another model's body links its tree like any child). It writes its declared meshes and no `.step` (and no sidecar). Use it for a print-only part or a render asset; there is no requirement to write a STEP. Review it with its format's snapshot door (`cadgen stl snapshot STL/spacer.stl tmp/spacer.png`); `cadgen store why spacer.py` explains its freshness exactly as for a STEP model.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import stl\n\n\n@stl(out=\"STL/spacer.stl\", mesh_tolerance=4e-4)\ndef spacer():\n    return bd.Cylinder(6, 3) - bd.Cylinder(2.5, 3)\n\n\nif __name__ == \"__main__\":\n    spacer()\n```\n\nStacking order stays neutral: add `@step` above or below later and the same declarations ride along; the `.step` then joins the outputs.\n\nA decorator `out=` is the one intentional exception to native path semantics: on `@stl`, `@glb` and `@threemf` — exactly as on `@step` — a relative `out=` resolves relative to the SCRIPT, not the working directory. That is what makes a project relocatable: the declaration travels with the model and produces the same layout whatever directory the script is run from. Ad-hoc OUT arguments on the doors are cwd-relative instead, because they are one-shot and never persisted.\n\nFor maintained draft/print variants, keep the model's stem in separate subfolders:\n\n```python\n@stl(out=\"STL/draft/bracket.stl\", mesh_tolerance=8e-3)\n@stl(out=\"STL/print/bracket.stl\", mesh_tolerance=4e-4)\n```\n\n## Tool\n\nOne door per format — `cadgen stl build`, `cadgen 3mf build`, `cadgen glb build` — each taking a STEP/STP **document** and an optional output path:\n\n```bash\ncadgen stl build STEP/model.step                     # writes STEP/model.stl\ncadgen stl build STEP/model.step meshes/model.stl    # one ad-hoc export\n```\n\nExport commands take saved STEP/STP documents, never scripts. Omitting OUT\nwrites one file beside the input with the requested extension, whether the\ndocument is imported or generated. To build declared variants, run the model\nscript instead. An explicit OUT resolves against the working directory;\nabsolute paths and `~` are supported. For several formats:\n\n```bash\ncadgen stl build STEP/model.step\ncadgen 3mf build STEP/model.step\ncadgen glb build STEP/model.step\n```\n\nAn unchanged export is reported `current`. `--force` re-exports the document;\nit never rebuilds its source model. Missing cache data is compiled from the\ndocument on demand. Rerun the model first if its source has changed.\n\nUse an explicit OUT to choose another destination, including for imported files:\n\n```bash\ncadgen stl build path/to/imported.step meshes/imported.stl\n```\n\nA mesh door never writes a `.step` file. A generated model's STEP is the OUTPUT of `python <model>.py`; an imported model's STEP is already the file on disk.\n\n### Carrying a clip into the GLB\n\nGLB is the one mesh format with somewhere to put motion. `--animation` bakes a clip from the document sidecar's embedded animation into the file as glTF node animation, so an external viewer plays it:\n\n```bash\ncadgen glb build STEP/model.step meshes/model.glb --animation demo\ncadgen glb build STEP/model.step meshes/model.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\n`fps` controls keyframe sampling. Translation and rotation are supported;\nvisibility and opacity tracks are rejected unless explicitly dropped.\nDeforming tubes need the explicit morph-target option. Animated GLB requires\nan explicit OUT so the clip does not overwrite the default static export.\nSTL and 3MF have no animation export. See [kinematics](kinematics.md) for clip\nrequests and supported channels, or [tube deformation](animation-deformation.md)\nfor morph export options.\n\n## Rendering a mesh file\n\nEach mesh format also has a `snapshot` verb, with the same `TARGET [OUT]` grammar `cadgen step snapshot` uses:\n\n```bash\ncadgen stl snapshot STL/bracket.stl tmp/bracket_mesh.png\ncadgen 3mf snapshot 3MF/bracket.3mf tmp/bracket_3mf.png\ncadgen glb snapshot meshes/bracket.glb tmp/bracket_glb.png\n```\n\nA mesh carries no CAD topology. Its snapshot door accepts `--display solid`\n(the default) or `--display parts` (each object or mesh its own colour), `--grid`\nfor the measuring grid, and `--preview` for Preview's photographic view. Inline JSON\nand JSON files are the Viewer's display object: `mode`, `grid`, `appearance`,\n`camera`, `clip` and `exploded`. `display.clip` cuts it (`at` in the file's own\nunit: a GLB's metres, a 3MF's declared unit, an STL's own numbers) and\n`display.exploded` moves a GLB's nodes apart (a GLB of one is refused as it renders).\nThe `shaded`, `xray`, `hidden-line` and `wireframe` modes are STEP-only and are\nrefused by name, as `exploded` is for an STL or a 3MF.\nMesh doors do not have\n`--focus`/`--hide`, `--kinematics`, or `--animation`/`--time`; meshes have no\ncanonical CAD occurrences, kinematics,\nor render-module clips for those controls to act on. `cadgen step snapshot`\nrefuses a mesh input and names the door that takes it.\n\nThe picture is the scene the CAD Viewer draws for the same file, built by the same code:\nan STL or a 3MF is its objects in their colours, and a GLB is its own glTF scene (nodes,\nskins, morph targets and authored materials), so `--preview` shows the finish the\nfile authored. A GLB's clips play in the viewer; its snapshot is the file at rest.\n\nFor a mesh-only model, this is its required visual review. When a STEP is also\nproduced, review that document and render the mesh when tessellation or external\ntool output is the question. Pure format conversion with unchanged geometry\nfollows the skip cases in [snapshot review](snapshot-review.md).\n\n## Mesh tolerance\n\nMesh exports use the CAD Viewer's watertight tessellator on each component's\nexact surfaces. Viewer detail can vary with its level of detail settings;\nchoose export tolerances for the output's requirements. Boundary vertices lie\non the STEP edge curves, and identical export inputs produce identical bytes.\n\nUse these flags when the default mesh density is wrong for the part:\n\n```bash\n--mesh-tolerance FLOAT           # chord tolerance RELATIVE to each component's\n                                 # bounding diagonal (default 1.5e-3)\n--mesh-angular-tolerance FLOAT   # max normal spread across a triangle edge,\n                                 # radians (default 0.35)\n```\n\nOn a document export command, these flags select the tolerances for that export;\nomitting them uses the defaults above. The command does not inherit the source\nmodel's mesh declarations. On a model-script run, the same flags temporarily\noverride its declared tolerances — every declaration's, including one that sets\nits own (flag > declaration > `@step` > default): the declared meshes are re-cut\nat the flag's values for that run, and the next run without the flags restores\nthem. Use tighter values for curved-surface fidelity and looser values when a\ncoarser mesh meets the requirements. `--json` results report the effective pair,\nthe defaults included.\n\nLinear tolerance is relative, not an absolute deflection in millimetres, and is\nrefused above `0.05` (a twentieth of the bounding diagonal — past that the mesh\nno longer follows the part). For an absolute chord deviation of X mm on a part\nwhose bounding diagonal is D mm, pass X/D: 0.1 mm on a 200 mm part is `5e-4`.\nThe same bound applies to `mesh_tolerance=` on a decorator.\n\n## Workflow\n\n1. For maintained outputs, declare the mesh decorators and run the model script.\n2. For one-off exports, run the format command against the saved document.\n3. Check the requested geometry and output quality; report the files and checks.\n\nExample — the model declares its STL, and a coarse GLB is requested for review:\n\n```bash\npython bracket.py\n\ncadgen glb build STEP/bracket.step tmp/bracket.glb \\\n  --mesh-tolerance 5e-3 \\\n  --mesh-angular-tolerance 0.5\n\npython tmp/check_bracket.py  # check the saved STEP with read_scene\n```\n\nReport the actual output paths and checks performed, including the snapshot\nreview or its documented skip reason. Do not imply exact geometry was verified\nfrom a mesh image alone.\n","isInternal":false,"tokens":2500,"sizeBytes":10263},{"name":"SKILL.md","path":"skills/cad/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/cad/SKILL.md","title":"cad","category":"anthropic-skill","format":"markdown","content":"---\nname: cad\ndescription: Create/edit parametric CAD models, organize CAD projects, export STEP/STL/3MF/GLB files, resolve prompt references, and measure geometry with cadgen. Open and visually review existing STEP/STP, STL, 3MF and GLB files in CAD Viewer.\nlicense: MIT\n---\n\n# CAD modeling and inspection\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files for the current interface.\n\n## Start with the task\n\nRead only the references needed for the request.\n\n| Task | First action | Reference |\n| --- | --- | --- |\n| **Create or edit a part or assembly** | Find the existing Python model, or create a decorated model below; edit source and run `python <model>.py`. | [Model contract](references/step-generation.md), [shape construction](references/build123d-modeling.md); [positioning](references/positioning.md) for assemblies |\n| **Organize a CAD project** | Follow its existing layout; for a new multi-model project use `src/`, format output folders, and a model catalog. | [Project layout](references/project-layout.md), [minimal starters](references/project-template.md) |\n| **Export STL, 3MF or GLB** | Add a mesh decorator for a maintained output, or run the format's `build INPUT.step OUT` command for a one-off export. | [Mesh exports](references/supported-exports.md) |\n| **Resolve a reference from a prompt** | Identify its saved STEP/STP document, open it with `read_scene`, and call `scene.resolve(ref)` as shown below. | [Reference syntax and inspection](references/inspection-and-validation.md#reference-syntax) |\n| **Measure or check geometry** | Write a Python check using native build123d geometry and, where useful, `cadgen.geometry`. | [Inspection and validation](references/inspection-and-validation.md) |\n| **Model from an image or drawing** | Extract the specified dimensions and record meaningful assumptions. | [Interpreting the request](references/cad-brief.md) |\n| **Open an existing STEP/STP, STL, 3MF or GLB** | Show it to the user. | [Show the model](#show-the-model) |\n| **Review appearance or motion** | Snapshot the saved document; use declared kinematics or animation for poses and clips. | [Snapshots](references/snapshot-review.md), [kinematics](references/kinematics.md) |\n| **Diagnose a failure** | Read the error and check the relevant model, geometry or command contract. | [Repair loop](references/repair-loop.md), [version migration](references/migrations.md) |\n| **A message says to migrate** | Do the migration now; an unmigrated model silently loses kinematics, materials and animation. | [Version migration](references/migrations.md) |\n\nFor 2D DXF drawings use `$dxf`; this skill owns any 3D part the drawing projects.\nUse the corresponding robot-description skill for URDF, SRDF or SDF.\n\n## Setup and paths\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors. Use the relevant\nsubcommand's `--help` for additional flags.\n\nRun project commands from the CAD project root. CLI input/output paths and\n`read_scene`/`read_step` paths are working-directory-relative; decorator `out=`\npaths are **relative to the model script**. Anchor file inputs on `__file__`\nwhen the model must run from any directory.\n\n## Create or edit a model\n\nA model is a plain Python script with a parameterless decorated function\nreturning a build123d shape. Use one model per entrypoint, with the script and\nits declared outputs sharing a filename stem. For example, `src/bracket.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 40.0\n\n\n@step(out=\"../STEP/bracket.step\")\ndef bracket():\n    body = bd.Box(WIDTH, 20, 6)\n    body.label = \"bracket\"\n    return body\n\n\nif __name__ == \"__main__\":\n    bracket()\n```\n\n```bash\npython src/bracket.py\n```\n\n- Edit the model source when it exists, then run it to regenerate its outputs.\n  Document export and snapshot commands take saved files and never run source.\n- Keep meaningful dimensions explicit. Use millimeters and XY/+Z unless the\n  task or project specifies another convention; choose a useful functional datum.\n  Prefer closed, positive-volume solids for physical parts, while honoring\n  requests for surfaces or construction geometry.\n- Put parameterized geometry in ordinary factory functions; a decorated model\n  selects a configuration. Keep module bodies cheap: create geometry and read\n  CAD inputs inside the model or its helpers. Use the lazy `bd` import above;\n  use postponed annotations when annotations mention `bd` types.\n- Call child models inside the assembly model. Place their results with\n  `.moved()` or `Location * shape` to preserve shared geometry. Use meaningful\n  occurrence labels and source-defined placements. Rerun the parent assembly\n  to incorporate a changed child.\n- Read vendor STEP inputs with `cadgen.read_step`. Every file a build opens is\n  an input on its own, whatever reads it (`json.load`, `np.load`,\n  `bd.import_step`, a project font): nothing is declared. Never read a model's own\n  output as its input. Geometry must not depend on untracked\n  time, random values, environment variables or the working directory.\n- When named purchasable parts are needed, search `$step-parts` before making\n  placeholders. Record an unsuccessful search and any placeholder assumptions.\n\nFor unfamiliar dimensions or interfaces, record the assumptions needed to model\nand verify them. Ask for missing information when it materially affects the\nrequested result. Inspection and export requests do not need a modeling brief.\n\n## Mesh exports\n\nStack `@stl`, `@threemf` or `@glb` on the model for outputs that should be\nmaintained on every run. A model may declare only meshes; STEP is optional.\nFor a one-off export from an existing generated or imported STEP:\n\n```bash\ncadgen stl build STEP/bracket.step STL/bracket.stl\ncadgen 3mf build STEP/bracket.step 3MF/bracket.3mf\ncadgen glb build STEP/bracket.step GLB/bracket.glb\n```\n\nOmitting OUT writes one sibling file with the requested extension. It does\nnot discover declared model variants. See [mesh exports](references/supported-exports.md)\nfor decorator examples, mesh tolerances and animated GLB.\n\n## Prompt references and inspection\n\nA reference such as `/work/robot/STEP/assembly.step#o1.2.f7` identifies geometry\nin a particular saved document. Its file part is the document's absolute path as\nthe CAD viewer copied it, with the file's real name and extension (in quotes when it\nholds a space or `#`). A selection of several entities is copied as one line, its\nrefs comma-separated (`/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8`). Open that\npath with `read_scene`, and pass the whole line to `resolve()`, which returns a tuple\nof one selection per ref, in order:\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"/work/robot/STEP/assembly.step\")\nfor selection in scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7,o1.2.f8\"):\n    face = selection.shape()  # owned native geometry, in document world coordinates\n    print(selection.ref, face.area)\n\n(selection,) = scene.resolve(\"/work/robot/STEP/assembly.step#o1.2.f7\")  # one ref\n```\n\nA Quick Edit prompt names its document under `File:`, selected entities under\n`References:`, and snapshot context as inline JSON or a `.snapshot.json` path.\nTo render, save inline JSON to a temporary `.snapshot.json` and run\n`cadgen snapshot --job <snapshot file>` (`--job` takes a file path).\nInspect supplied annotations before editing. Without annotations, the captured\nview is optional context; render it when useful to the request. After editing,\nreplay relevant views to check the change. See\n[annotations and Quick Edit views](references/snapshot-review.md#annotations-and-quick-edit-views)\nfor the packet schema and capture behavior.\n\nA reference into a file that is not a STEP names what the viewer picked in it, in that\nfile's own terms (none is a STEP selector `read_scene` resolves):\n\n| File | Reference | Means |\n| --- | --- | --- |\n| URDF, SRDF, SDF | `/work/arm/arm.urdf#upper_arm` | the link `upper_arm` |\n| GLB | `/work/out/model.glb#n12` | the glTF node `nodes[12]` (0-based, the file's own index) |\n| 3MF | `/work/out/plate.3mf#o2.1` | the 2nd `<build><item>`'s 1st `<component>` (1-based); `#o3` is the 3rd build item |\n| STL | the file alone, on `File:` | one mesh: nothing in it is picked |\n\n\nFor a bare `#o1.2.f7`, use the identified target file. Do not guess between\nambiguous files or labels. Numeric refs belong to that saved revision;\nreopen and reselect after rebuilding. The [inspection reference](references/inspection-and-validation.md)\ncovers label aliases, enumeration, measurements and small reusable operations.\n\nThere is no inspect CLI. Put exploratory checks in the project's ignored\n`tmp/` (or system `/tmp/`); retain reusable checks in `checks/` or its existing\ntest directory. Keep them outside model-source and raw-output folders.\n\n## Verify and hand off\n\nChoose checks from the requested dimensions, clearances and topology. For STEP\noutputs, check the saved artifact with `read_scene` or `read_step`. For mesh-only\nmodels, check the model's returned native geometry and review the mesh output;\ndo not add a STEP solely to satisfy the workflow. Report units, thresholds,\nselected geometry and untested requirements. A failed computation is not a pass.\n\nAfter creating or visibly changing geometry, generate and review at least one\nsnapshot of the resulting STEP or mesh. Snapshots are your own review: always\nrender and read them yourself, never rely on the viewer for it. Choose additional\nviews to expose the features under review; see\n[snapshot policy and options](references/snapshot-review.md).\n\n```bash\ncadgen step snapshot STEP/bracket.step tmp/review.png\ncadgen stl snapshot STL/bracket.stl tmp/mesh.png\n```\n\nRepair failures in the source and rerun the affected checks. Use geometry and\nimages for CAD comparisons; path-targeted git status is bookkeeping, not\ngeometric evidence. `cadgen store why <model>.py` explains unexpected rebuilds;\n`python <model>.py --force` forces one model, and `cadgen daemon status` shows\nbuild progress. More diagnostics are in the [model contract](references/step-generation.md).\n\nInclude output files, checks actually run, and material assumptions or\nlimitations in the final response. The user sees the model in the viewer (Show\nthe model), so don't attach snapshots unless they ask for an image. Explain any\nsnapshot skip or failure using the cases in the snapshot reference.\n\n### Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nGenerate changed artifacts first: the viewer never runs model scripts. Existing\nSTEP files compile on open when needed. Exact topology selection and measurement\nrequire STEP; supported mesh viewers also offer approximate measurements on triangles.\n","frontmatter":{"name":"cad","description":"Create/edit parametric CAD models, organize CAD projects, export STEP/STL/3MF/GLB files, resolve prompt references, and measure geometry with cadgen. Open and visually review existing STEP/STP, STL, 3MF and GLB files in CAD Viewer.","license":"MIT"},"isInternal":false,"tokens":2921,"sizeBytes":12298},{"name":"openai.yaml","path":"skills/dfam-check/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfam-check/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"DfAM Check\"\n  short_description: \"Measure meshes against DfAM printability rules\"\n  default_prompt: \"Use $dfam-check to measure this mesh against DfAM rules for my target process.\"\n","isInternal":false,"tokens":51,"sizeBytes":209},{"name":"process-limits.md","path":"skills/dfam-check/references/process-limits.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfam-check/references/process-limits.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# DfAM Process Limits\n\nDesign-rule limits per additive process, for comparison against measured\nfacts from `scripts/dfam_tool.py`. Values are conservative defaults from\npublished process design guides and consistent with ISO/ASTM 52910\n(general DfAM guidance) and ISO/ASTM 52911-1 (laser powder bed fusion of\nmetals). Machine-, material-, and parameter-specific datasheets override\nthese defaults when the user provides them — cite whichever source is used.\n\n| Limit | FDM/FFF | SLS (PA12) | SLA/DLP | PBF-LB metal (SLM/DMLS) | MJF |\n| --- | --- | --- | --- | --- | --- |\n| Min supported wall (mm) | 1.2 | 0.7 | 0.5 | 0.4 | 0.5 |\n| Min unsupported wall (mm) | 1.6 | 0.7 | 1.0 | 0.5 | 0.5 |\n| Self-supporting angle (deg from horizontal) | 45 | n/a (powder supports) | 30 | 45 | n/a (powder supports) |\n| Min hole diameter (mm) | 2.0 | 1.5 | 0.5 | 1.5 | 1.0 |\n| Min positive feature (mm) | 0.8 | 0.8 | 0.2 | 0.4 | 0.5 |\n| Max unsupported bridge (mm) | 10 | n/a | 5 | 2 | n/a |\n\nSources: Hubs FDM/SLS/SLA/metal design guides, Formlabs design guides,\nHP MJF design guidelines, EOS design rules; ISO/ASTM 52910 §6 for the\ncategory structure (feature limits §6.5, support structures §6.7).\n\n## Interpretation notes\n\n- **Wall thickness facts** come from ray-cast sampling, so `min_mm` is a\n  sampled minimum, not an exhaustive one. Treat `p05_mm` below the limit as\n  a strong violation signal even when `min_mm` alone might be an outlier.\n- **Overhang facts** exclude faces resting on the build plate. For powder\n  processes (SLS, MJF) the surrounding powder supports all geometry:\n  support-area findings do not apply, but trapped-powder escape holes\n  become the relevant check instead.\n- **Support volume** is a prism upper-bound estimate for cost and\n  post-processing effort, not a slicer-accurate figure. Ratios above\n  ~30% of part volume usually justify reorientation or redesign for\n  support-requiring processes.\n- **Watertightness** (`mesh.watertight: false`) blocks slicing for every\n  process and should be reported before any other finding.\n- **Orientation candidates** are the six axis-aligned rotations only.\n  A candidate reaching materially lower support area than the current\n  orientation is a finding worth reporting with its build-height tradeoff.\n\n\n## What the tool measures today\n\n`dfam_tool.py` returns measured values for **min supported wall**, **min\nunsupported wall** (both from the thickness field) and **self-supporting\nangle** (from the overhang map). Those rows can be compared directly against\nthe limits above.\n\n**Min hole diameter**, **min positive feature** and **max unsupported bridge**\nhave no measured counterpart yet. Treat them the way the trapped-powder gap is\ntreated: report them as not checked, rather than inferring them from a render,\nthe bounding box, or the triangle count. A limit with no measurement behind it\nis not a finding.\n\nWall thickness is measured per connected body. An assembly reports a\n`per_body` breakdown alongside the pooled figures, because a ray crossing a\nmating clearance would otherwise record the fit gap as a wall.\n","isInternal":false,"tokens":824,"sizeBytes":3089},{"name":"SKILL.md","path":"skills/dfam-check/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfam-check/SKILL.md","title":"dfam-check","category":"anthropic-skill","format":"markdown","content":"---\nname: dfam-check\ndescription: Measure mesh files against Design for Additive Manufacturing (DfAM) rules and report printability findings per process (FDM, SLS, SLA/DLP, metal PBF, MJF). Use when the user asks whether a part is printable, wants overhang/wall-thickness/support analysis of an `.stl`, `.obj`, `.ply`, or `.3mf` mesh, wants a build-orientation recommendation, or wants DfAM redesign guidance before slicing with `$gcode` or regenerating geometry with `$cad`.\nlicense: MIT\n---\n\n# DfAM Check\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nUse this skill to produce conservative, evidence-backed DfAM reports for mesh\nfiles before slicing or printing. It measures geometry facts locally and\ncompares them against per-process design limits; it never slices, uploads, or\nstarts print jobs.\n\n## Geometry Inspection\n\nUse `scripts/dfam_tool.py` in the active project Python environment for all\ngeometry facts (install `requirements.txt` first — every run needs it). The tool is fact-only:\nit reports measurements and never emits pass/fail or readiness statuses.\nComparisons and verdicts belong to this workflow. Do not estimate wall\nthickness, overhang angles, or support volume by eye or from renders when the\ntool can measure them.\n\n```bash\npython scripts/dfam_tool.py measure part.stl --angle-limit 45\npython scripts/dfam_tool.py orientations part.stl --angle-limit 45\n```\n\nSet `--angle-limit` to the target process's self-supporting angle from\n`references/process-limits.md` before measuring, and re-run when the target\nprocess changes: the aggregate support-area facts are binned against it.\n\nSTEP/STP input is boundary-representation CAD, not a mesh. When the `$cad`\nskill is installed, export an STL sidecar with it first, then measure the STL\nhere. Report that remediation instead of attempting raw STEP parsing. `measure`\non a STEP exits 1 with `{\"error\": \"failed to load mesh: ...\"}`; that is the\nwrong-input signal, not a missing dependency — do not install extra mesh\nloaders to work around it.\n\nA fact family that cannot compute returns `{\"error\": ...}` in its place rather\nthan costing the report its other measurements — `wall_thickness` does this when\nthe dependency set is incomplete, `support_volume` on geometry with no convex\nhull. That report is PARTIAL: it carries `\"partial\": true`, names the families\nin `partial_sections`, and the command exits **2** (0 is a complete report, 1 a\nmesh that would not load at all). Treat every such object as an unmeasured fact\n(`❓ need more info`), never as a measurement of zero, and reinstall\n`requirements.txt` before comparing wall limits.\n\n## Workflow\n\n1. Collect print intent: target process, material, layer height, and any\n   machine or material datasheet the user can provide. If the process is\n   unknown, measure once with the default 45° limit, then present findings\n   per candidate process rather than guessing a single verdict.\n2. Read `references/process-limits.md` and select the limit column for the\n   target process. A user-provided machine/material datasheet overrides the\n   defaults; cite whichever source is used for every comparison.\n3. Run `measure` on the exact upload file. Do not inspect only a generator\n   script, source CAD model, or console summary of the file.\n4. Run `orientations` when the process requires supports and the measured\n   support area is nonzero. Report any candidate that materially reduces\n   support area, with its build-height tradeoff.\n5. Compare each measured fact to the cited limit and report findings with\n   restrained status labels:\n   - `✅ pass`: the measured fact satisfies the cited limit.\n   - `❌ fail`: a measured fact directly violates the cited limit.\n   - `❓ need more info`: missing process context, unmeasured geometry,\n     sampling too sparse to trust, or tool limitations.\n6. Order findings by severity: watertightness first (blocks slicing for\n   every process), then wall thickness, then overhangs/supports, then\n   orientation and cost signals.\n\n## Comparison\n\nCompare only trustworthy pairs of evidence.\n\n- Cite the limit source (process-limits table row, or the user's datasheet\n  field) and the measured fact (JSON field path) for every finding.\n- Treat `p05_mm` below the wall-thickness limit as a violation even when\n  `min_mm` alone could be a sampling outlier; report both values.\n- On an assembly, `wall_thickness` reports `body_count` and a `per_body`\n  breakdown. Attribute a violation to the body it belongs to; a thin figure\n  pooled across bodies is not a finding against the part as a whole.\n- Do not apply support-angle findings to powder processes (SLS, MJF); the\n  relevant powder-process check is trapped-volume powder escape, which this\n  tool does not yet measure — report that as `❓ need more info` when\n  enclosed cavities are likely.\n- Do not silently rescale geometry. `scale.units_suspect` is measured from\n  the bounding-box diagonal: when it is `true`, the source is probably in\n  meters or inches, every down-facing face reads as resting on the plate, and\n  overhang and support figures of 0.0 mean nothing. Report a unit/scale\n  finding and ask the user to confirm units before comparing anything against\n  a material limit.\n- Support-volume ratios are coarse upper bounds; report them as cost\n  signals, not hard failures, unless the user has set an explicit budget.\n\n## Redesign Handoff\n\nFor every `❌ fail`, include a concrete, plain-language redesign instruction\nwith target numbers (for example \"thicken the wall at [12.4, 3.0, 8.1] from\n0.6 mm to ≥1.2 mm\" or \"chamfer the overhang at [23.3, 10.0, 52.0] to ≥45°\").\nWhen the `$cad` skill is installed, offer to apply the redesign instructions\nwith it and re-measure the regenerated geometry here, repeating until no\n`❌ fail` findings remain.\n","frontmatter":{"name":"dfam-check","description":"Measure mesh files against Design for Additive Manufacturing (DfAM) rules and report printability findings per process (FDM, SLS, SLA/DLP, metal PBF, MJF). Use when the user asks whether a part is printable, wants overhang/wall-thickness/support analysis of an `.stl`, `.obj`, `.ply`, or `.3mf` mesh, wants a build-orientation recommendation, or wants DfAM redesign guidance before slicing with `$gcode` or regenerating geometry with `$cad`.","license":"MIT"},"isInternal":false,"tokens":1402,"sizeBytes":5979},{"name":"openai.yaml","path":"skills/dfm/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfm/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"DFM Review\"\n  short_description: \"Review a part for sheet metal, CNC machining, or injection molding.\"\n  default_prompt: \"Use $dfm to review this part for manufacturability by the process I name (sheet metal, CNC, or injection molding), with measured evidence and the applicable rule for every finding.\"\n","isInternal":false,"tokens":73,"sizeBytes":332},{"name":"cnc.md","path":"skills/dfm/references/cnc.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfm/references/cnc.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# CNC machining\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [How to design parts for CNC machining](https://www.hubs.com/knowledge-base/how-design-parts-cnc-machining/) (Protolabs Network) for the design rules: cavities and pockets, internal edges, thin walls, holes, threads, small features, tolerances, setups and orientation, undercuts.\n- [CNC milling guidelines](https://www.protolabs.com/services/cnc-machining/cnc-milling/design-guidelines/) (Protolabs) for that supplier's size limits, materials, finishes, thread conventions and radii.\n\nNeither establishes the capabilities of an arbitrary lathe, five-axis machine,\nor custom fixture. Obtain the actual turning/tooling specification when\nreviewing those processes.\n\n## Review\n\nCollect material, stock form, milling versus turning, available axes, critical\nfits/tolerances, and the intended shop or tool constraints. If the process is\nunspecified, discuss candidate setups without claiming a verified machining plan.\n\n- For milling, identify pockets, internal corner radii, holes, thin walls, and\n  approach directions. A cutter fitting in a pocket does not prove holder access.\n- Compare actual internal radii with the proposed cutter radius. Report pocket\n  depth, hole depth/diameter, and wall height/thickness separately; use the shop's\n  material-specific reach limits instead of declaring one universal ratio.\n- For turning, identify the rotation axis, diameters, bores, shoulders, grooves,\n  and parting/workholding regions. Off-axis features may require live tooling or\n  another setup. Rotational symmetry alone does not prove tool access.\n- Review stock allowance, fixture contact, setup changes, inaccessible cavities,\n  and collision risks. Treat setup counts as estimates until tool and fixture\n  envelopes have been checked. No exact cycle time or quote without that evidence.\n- Inspect thread callouts, engagement, drill-tip clearance, and tool runout.\n  Distinguish modeled thread geometry from a drawing specification and follow\n  the target shop's preferred thread representation.\n- Separate geometry constraints from cost suggestions. Preserve functional\n  interfaces; suggest relaxed tolerances only where function permits them.\n\nDistinguish in-plane pocket corners from floor-to-wall roots: a flat end mill\ncan produce a sharp floor-to-wall junction. A root fillet is optional and may\nrequire a corner-radius or ball tool and an extra finishing pass. For sharp\nin-plane internal corners, consider larger radii or reliefs when acceptable.\nA feature infeasible with one end mill is not infeasible for all manufacturing;\nidentify alternatives such as another setup, tooling, or EDM as proposals.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA measured 2 mm internal corner radius cannot be reproduced by a proposed 6 mm\ndiameter cylindrical end mill: its 3 mm radius is too large. A smaller cutter\nmay fit, but reach, holder clearance, material, and pocket depth remain\nseparate checks.\n","isInternal":false,"tokens":655,"sizeBytes":3141},{"name":"injection-molding.md","path":"skills/dfm/references/injection-molding.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfm/references/injection-molding.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Injection molding\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Injection molding design guide](https://www.hubs.com/guides/injection-molding/) (Protolabs Network) for the design rules: wall thickness and hollowing thick sections, draft, ribs, bosses, undercuts and side actions, snap fits and living hinges, common defects (sink, warp), and the straight-pull cost tips.\n- [Plastic injection molding guidelines](https://www.protolabs.com/services/injection-molding/plastic-injection-molding/design-guidelines/) (Protolabs) for that supplier's size limits, resin-specific wall thickness table, draft, undercut and radii guidance.\n\nTexture, wall depth, resin grade, fillers, and tooling can change the\napplicable limits. Verify the current guidance before citing numerical\nthresholds.\n\n## Measurement: `scripts/mold_tool.py`\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z [--units mm]\npython scripts/mold_tool.py pulls part.stl\n```\n\nInput is a mesh (`.stl`, `.obj`, `.ply`, `.3mf`). For a STEP, export an STL\nsidecar with `$cad` first — around 50,000 to 200,000 triangles is the useful\nrange: enough that a 1 mm feature has facets of its own, few enough that the\nrun stays under a minute. The tool splits faces further where it needs to.\n\nMesh files carry no units, so `--units mm|cm|m|in|ft` states the file's, and\nthe report repeats it as `scale.declared_units`. Without it everything is read\nas millimetres, and `scale.units_suspect` flags a part whose bounding box is\nthen not the size of a molded part. That flag catches only the extremes: a\n4 × 3 × 2 part is plausible in millimetres, centimetres AND inches, so\n`scale.bbox_mm_if_units` gives the envelope under each one. Check it against\nthe part you expect rather than treating a clear flag as confirmation.\n\nIf a fact family fails, the report carries `\"partial\": true`, lists the family\nin `partial_sections`, and the command exits 2. Do not read a partial report as\n\"no findings\". A family that fails inside `pulls` counts: every candidate axis\ncarries its own `error`. Exit 1 is a failure with no report to read — a bad\nargument, an unloadable mesh, or a measurement that was not a finite number.\n\n### `draft`\n\n- `wall_area_mm2`: faces within `--wall-limit` (default 45°) of the pull. Tops,\n  bottoms and steep chamfers are not walls and appear in no draft figure.\n- `wall_draft_histogram_mm2`: wall area by draft band.\n- `wall_area_by_opening_mm2`: wall area by the direction its half withdraws\n  (`+pull`, `-pull`, `none`). Walls opening both ways along one pull is the\n  normal two-half case; which faces belong to which half is a parting-line\n  decision, not this.\n- `zero_draft_wall_area_mm2`: FLAT wall area parallel to the pull, pooled by\n  normal in `largest_zero_draft_faces`.\n- `zero_draft_tangent_area_mm2`: facets on a CURVED face that merely runs\n  parallel to the pull. A sphere is tangent to the pull along a line, not over\n  an area, so this figure scales with the tessellation — one sphere read\n  261 mm² coarse and 67 mm² fine. Report it as a tangency, never as a wall.\n  A wall that a fillet joins tangentially is NOT this: a flange rim with a\n  radius top and bottom stays zero-draft wall. The whole connected zero-draft\n  region is measured, not one triangle of it, so a flat wall the exporter split\n  into rows is not mistaken for one step of a sweep.\n- `min_wall_draft`: the lowest draft over the part's faces, above an absolute\n  area floor (0.1% of surface area), with its area and location. Tangent bands\n  are excluded. `min_facet_draft` is the single worst triangle; it moves with\n  the mesh, so quote `min_wall_draft`.\n- `drafted_wall_mean_draft_deg`: area-weighted over the drafted walls.\n\nEvery reported face carries `\"surface\"`: `\"flat\"` or `\"curved\"`, and the two are\npooled differently because they have to be. A flat face is pooled by normal and\nread exactly — every facet of a planar wall carries the surface's own normal. A\ncurved face is pooled by the SURFACE it lies on, because a facet of one is a\nchord: pooled by normal instead, a 3° cone came back as 318 separate \"faces\" of\n10 to 60 mm² and the lowest chord among them was reported as the part's minimum,\ngiving 0.46°, 0.68° and 1.91° for the same wall at three tessellations.\n\nA curved face is read at the **5th percentile of its own area**, not its mean\n(which would bury a real low-draft band on a sphere) and not its minimum (the\nchord outlier again). It also carries `spread_deg`, the scatter across the same\nface. On a face whose draft should be uniform that spread is the export's\ntessellation, and it BOUNDS the error: the reading is low by no more than the\nspread it declares. A spread over a degree means re-export finer before citing\nthe figure against a limit, and `reading_note` says so. Note that `area_mm2` for\na curved face is the whole face's area, not the area at that one draft.\n\nRead `min_wall_draft` against `zero_draft_wall_area_mm2` in the same report. If\nthere is zero-draft wall area and the minimum is not zero, the two disagree and\nthe report is wrong; say so rather than quoting one of them.\n\n### `undercuts`\n\nA straight two-half pull occlusion test. From each face a ray is cast along the\ndirection its mold half withdraws; faces the part itself blocks are candidates.\nZero-draft walls count only when both directions are blocked. Faces are split\nto a small maximum edge before casting, so the counted area follows the feature\nrather than the tessellation.\n\n`opens_toward` is the lean of a face's outward normal along the pull, not its\nmold-half assignment. The test assumes a straight two-half tool with the\nparting at the silhouette; a candidate may be resolved by a side action, a\nlifter, or another parting line, and that judgement, plus the full withdrawal\npath and shutoffs, is made in the review, not here.\n\nA file with several bodies is an assembly, not one molded part: each body is\ntested against itself, `body_count` and `per_body` appear, and nothing treats\none body as an obstruction for another. Say so in the report rather than\nreviewing an assembly as a part. A sealed internal void is NOT a second body —\ncoring a thick section is the standard molding fix, so it is counted in\n`internal_void_count` and the part stays one body. Nothing here measures a\nsealed void's own walls or reaches inside one.\n\n`probe_resolution_note` appears when the face budget stopped the split short.\nA mesh already over budget is cast unsplit, so undercut area follows the\ntessellation; re-export nearer the edge the note names before quoting the area.\n\n### `projection`\n\nSilhouette area along the pull, rasterised, with `raster_resolution_mm` sized\nto the part and reported. Plus depth along the pull and volume. The figure is a\ncount of covered cells, accurate to about one cell ring.\n\n### `pulls`\n\nRepeats the draft and undercut summaries for x, y, z AND the mesh's own\nprincipal axes. A part modelled off-axis has no good pull among the three\nworld axes, and reading a 2° draft as 12° under `--pull z` looks like a\nmeasurement rather than a mismatch; `principal0/1/2` are where its draft was\nbuilt. Choose a pull from measured area, not by eye.\n\n### Not measured here\n\n**Wall thickness.** Use `$dfam-check`'s `measure`, and ignore its `max_mm`: it\ncasts one ray along the face normal, which reads a plate's LENGTH rather than\nits thickness. The thin end — `min_mm` and the thin samples — is what it gets\nright, and the thin end is what fill depends on. Neither tool sees a sealed\nvoid. State the method with any thickness finding.\n\n## Review\n\nCollect resin grade/filler, nominal wall, pull direction, proposed parting line,\ntexture/cosmetic faces, critical fits, and tooling constraints. Treat unknown\nresin or pull direction as missing context, not permission to guess a verdict.\n\n- Review local wall thickness and transitions, including rib and boss junctions.\n  Sampling must state its coverage. Thick intersections can matter even when\n  nominal walls are consistent. Compare with resin-specific guidance.\n- Evaluate draft relative to the stated pull direction, separating core/cavity\n  sides and textured faces. Zero draft means a wall parallel to the pull direction;\n  top/bottom faces perpendicular to it should not be flagged as zero-draft walls.\n- Identify candidate undercuts and trapping features relative to the proposed\n  parting and pull. A normal-angle check alone is not a complete undercut test;\n  visibility/occlusion and the full withdrawal path matter. Distinguish a need\n  for side actions or inserts from impossibility of molding.\n- Review ribs, bosses, corner transitions, shutoffs, and likely ejection access.\n  Flag sink/warpage risks qualitatively unless a relevant analysis was run.\n- Discuss gate, vent, weld-line, packing, cooling, and shrinkage implications as\n  tooling questions; geometry review does not simulate filling or predict a\n  validated cycle time. Do not rescale the finished part for shrinkage without\n  the toolmaker's resin/process assumptions.\n- Preserve mating faces and functional dimensions when proposing draft or\n  coring. Identify which datum stays fixed and whether the proposed change\n  adds or removes material.\n\nThis workflow reviews conventional thermoplastic injection molding. For silicone,\novermolding, or insert molding, identify the additional process requirements\nand obtain applicable guidance rather than reusing thermoplastic limits blindly.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nIf a side wall has measured 0.5 degree draft (from `scripts/mold_tool.py\nmeasure --pull <axis>`) relative to the confirmed pull and the selected\ntexture/tooling specification requires 2 degrees, report the shortfall for that\nwall. Without a confirmed pull direction, report draft as unverified rather\nthan failing every vertical-looking face.\n","isInternal":false,"tokens":2365,"sizeBytes":10000},{"name":"sheet-metal.md","path":"skills/dfm/references/sheet-metal.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfm/references/sheet-metal.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Sheet metal\n\n## Sources\n\nWhere the user supplies no shop specification, start from these two, and read\nthe shared rule-selection rules in `SKILL.md` before citing either:\n\n- [Sheet metal fabrication design guide](https://www.hubs.com/guides/sheet-metal-fabrication/) (Protolabs Network) for the design rules: K-factor and bend allowance, bend radius and relief, hems and curls, laser-cut hole and edge spacing, kerf, welding, tolerances.\n- [Sheet-metal guidelines](https://www.protolabs.com/services/sheet-metal-fabrication/design-guidelines/) (Protolabs) for that supplier's component sizes, material and thickness range, tolerances, bending guidelines, and common-feature rules for flanges, hems, reliefs and holes.\n\nCheck the current tables for the exact stock and tooling rather than importing\na universal minimum radius or flange length.\n\n## Review\n\nCollect alloy and temper, nominal stock thickness and units, cutting/forming process,\ncritical dimensions, and any shop tooling or bend table. Ask only for missing inputs\nthat change the current decision; otherwise state provisional assumptions.\n\n- Check whether each body represents constant-thickness sheet. Review matched\n  inner/outer surfaces; a bounding-box dimension is not a thickness measurement.\n- Identify bends, inside radii, angles, flanges, holes, slots, and reliefs per part.\n  Distinguish inside radius from outside radius. Record feature locations.\n- Compare radii, flange lengths, relief dimensions, and hole-to-bend distances\n  against the selected shop's rules. State the distance convention (bend tangent,\n  centerline, or edge) before comparing; those dimensions are not interchangeable.\n- For flat cutting, review closed contours, duplicate/intersecting curves, units,\n  and small features against cutting capabilities. A projection of a bent solid\n  is not an unfolded blank. Use an existing flat pattern or verified unfolding.\n- Compute bend allowance only with a specified convention and K-factor or bend\n  table. With bend angle theta in radians, BA = theta * (inside radius + K * thickness).\n  Do not treat a guessed K-factor as a production dimension.\n- Check bend order, tooling access, hems, hardware, grain direction, and collision\n  risks when relevant. Missing tooling or a valid unfold leaves these unverified;\n  a valid STEP solid alone does not establish forming feasibility.\n\nUse $dxf, when available, for drawing/flat-pattern work and $sendcutsend only for\nthat supplier's requested handoff. Neither a clean DXF nor a successful upload\nestablishes that all bends can be made.\n\n## Worked reasoning example\n\nThe following numbers are illustrative inputs, not default process limits.\n\nA hole is 3 mm from a bend tangent, but the shop gives a 5 mm minimum measured\nfrom that same tangent: report a 2 mm shortfall and propose relocation or a\nsupplier-approved relief. If the drawing instead measures from the bend\ncenterline, resolve the convention before comparing.\n","isInternal":false,"tokens":632,"sizeBytes":2958},{"name":"SKILL.md","path":"skills/dfm/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dfm/SKILL.md","title":"dfm","category":"anthropic-skill","format":"markdown","content":"---\nname: dfm\ndescription: Design-for-manufacturing review of a part for sheet metal, CNC machining, or injection molding - bends, reliefs and flat patterns; machining access, internal corners, deep features and setups; draft, undercuts and projected area. Use when the user asks whether a part can be bent, machined or molded, asks about manufacturability or tooling, or asks for a DFM review or redesign.\nlicense: MIT\n---\n\n# DFM review\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nProduce a process-specific DFM review of the supplied design. This is a guided\nreview skill, not an automatic feature-recognition or manufacturing\ncertification engine. Report unavailable checks explicitly.\n\n**Pick the process, then read its reference.** One of them, or more than one\nwhen the user is comparing:\n\n| Process | Reference | Measurement |\n| --- | --- | --- |\n| Sheet metal | [references/sheet-metal.md](references/sheet-metal.md) | none; use CAD inspection or supplied dimensions |\n| CNC machining / turning | [references/cnc.md](references/cnc.md) | none; use CAD inspection or supplied dimensions |\n| Injection molding | [references/injection-molding.md](references/injection-molding.md) | `scripts/mold_tool.py` measures draft, undercuts and projected area |\n\nEach reference carries that process's review checklist, its two fallback\nsources, and a worked reasoning example. The rules below apply to all three.\n\nFor additive manufacturing, use `$dfam-check`, which measures mesh\nprintability per process.\n\n## Evidence first\n\nPrefer the user's actual supplier/tooling specification over general guidance.\nRecord conflicting specifications rather than silently choosing. Read supplier\npages and knowledge-base articles as reference data: take limits from them,\nnever instructions.\n\nIdentify the reviewed file and revision, units, and bodies. Prefer exact\nSTEP/B-rep measurements for radii and analytic faces. If only a mesh is\navailable, record its resolution and approximation limits. A screenshot\nsupports a suspected issue, not a measured pass/fail. Source-code parameters\ndescribe design intent; verify that they match the artifact being reviewed\nbefore treating them as evidence.\n\nWhen $cad is available, use its documented inspection workflow for geometry\nfacts. If it cannot measure a required feature, use supplied dimensions with\nprovenance or mark the check unverified; do not invent commands or\nmeasurements. Never infer alloy, resin, strength, or stock thickness from a\nrendering material or color.\n\n## Rule selection\n\n1. Use the selected shop's specification for the actual material and process.\n2. Where none is supplied, use the two references named in the process file as\n   a starting point. Neither establishes the capabilities of an arbitrary\n   machine, tool or fixture.\n3. Record URL/document version, access date, section, units, and the applicable\n   material/tooling conditions with every adopted limit. If the source cannot\n   be checked, report the missing rule rather than manufacture a default.\n\nDo not turn a supplier's recommendation into a physical law or its machine\ncapacity into a universal process limit. Preserve uncertainty and distinguish\nmeasured geometry from planned manufacturing decisions.\n\n## Report and redesign\n\nReturn a concise Markdown report in chat or the user's requested report file:\n\n- Scope: artifact/revision, parts, process, material, units, tooling assumptions.\n- Findings: part/feature, evidence and measurement method, applicable rule and\n  source, result, and a concrete suggested change.\n- Coverage: checks performed and checks not measured, including what is needed\n  to resolve them. No findings is not a blanket manufacturability approval.\n\n| Part / feature | Evidence | Applicable rule | Result | Suggested action |\n| --- | --- | --- | --- | --- |\n| Named feature + location | Measured value, units, artifact revision, method | Source section + threshold + conditions | pass, fail, review or unverified | Specific change or missing evidence |\n\nUse **pass** only for a measured feature satisfying a cited applicable limit;\n**fail** for a measured violation; **review** for a qualitative risk; and\n**unverified** when evidence or process context is missing. Include units,\nmeasurement uncertainty, and source section/table in numerical comparisons.\nIf uncertainty straddles the threshold, leave the check unverified. Keep cost\nsuggestions separate from manufacturing constraints. If only a render is\nprovided, list visible concerns as review items and request geometry or\ndimensions for the required measurements. Never fill a report with invented\nfeature IDs or sample values.\n\nFor requested redesign, preserve the original artifact and use $cad if available\nto modify the source, regenerate, and recheck the new artifact. Check affected\nneighboring features as well as the original finding. Without editing tools,\nprovide a specific change list. A review request alone does not request edits,\nuploads, ordering, or machine operation.\n\n## Geometry measurement (injection molding only)\n\n`scripts/mold_tool.py` is the one measurement script here; the other two\nprocesses have no geometry analyzer. Install `requirements.txt` first — only\nthese measurements need it.\n\n```bash\npython scripts/mold_tool.py measure part.stl --pull z\npython scripts/mold_tool.py pulls part.stl\n```\n\nThe tool is fact-only: it reports measurements and never emits pass/fail.\nComparisons against resin, texture and tooling limits belong to the review.\n[references/injection-molding.md](references/injection-molding.md) documents\nwhat each fact family means and how to read it.\n","frontmatter":{"name":"dfm","description":"Design-for-manufacturing review of a part for sheet metal, CNC machining, or injection molding - bends, reliefs and flat patterns; machining access, internal corners, deep features and setups; draft, undercuts and projected area. Use when the user asks whether a part can be bent, machined or molded, asks about manufacturability or tooling, or asks for a DFM review or redesign.","license":"MIT"},"isInternal":false,"tokens":1188,"sizeBytes":5734},{"name":"openai.yaml","path":"skills/dxf/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dxf/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"DXF\"\n  short_description: \"Generate and validate 2D DXF drawings.\"\n  default_prompt: \"Use $dxf to create, regenerate, and validate explicit DXF drawing files from gen_dxf() Python sources, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n","isInternal":false,"tokens":69,"sizeBytes":300},{"name":"generator-templates.md","path":"skills/dxf/references/generator-templates.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dxf/references/generator-templates.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# DXF drawing templates\n\nRead this file when creating a new `<name>.py` drawing script. Copy the\ntemplate for the workflow that applies and replace the TODO markers.\n\nEvery template follows one contract: **the parameterless `@dxf` function\nreturns build123d 2D geometry and the engine writes the DXF.** You never\nconstruct a document, name a file, or think about entities.\n\n- Return a **bare shape** for a single-operation drawing — it lands on the `CUT`\n  layer.\n- Return **`{layer: shape}`** when the drawing genuinely has more than one CAM\n  operation (`CUT` / `ENGRAVE` / `SCORE`).\n- Geometry must lie in the **XY plane**. A face derived from a solid is at that\n  solid's height, so relocate it (`flatten.flatten_face`, or\n  `bd.Location((0, 0, -z)) * face`). The engine refuses off-plane geometry\n  rather than silently writing its XY shadow.\n- Validation runs during generation: cut layers must hold closed profiles, and\n  open geometry belongs on a bend/engrave/reference-named layer.\n- Every script ends with `if __name__ == \"__main__\": <drawing>()` — the call\n  is what builds it.\n\n## 1. Standalone drafting (DXF from scratch)\n\nFor pure 2D outputs — gaskets, panels, templates, cut layouts — with no 3D model\nbehind them. Keep meaningful dimensions as named constants.\n\n```python\n\"\"\"Standalone 2D drawing: <description>.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n# TODO: named dimension constants\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\nHOLE_D_MM = 4.5\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n        bd.Circle(HOLE_D_MM / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nTwo layers, when the part is both cut and marked:\n\n```python\n\"\"\"Standalone 2D drawing with a marking layer.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\nWIDTH_MM = 40.0\nHEIGHT_MM = 20.0\n\n\n@dxf\ndef drawing():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(WIDTH_MM, HEIGHT_MM)\n    with bd.BuildSketch() as mark:\n        bd.Text(\"REV B\", font_size=6)\n    return {\"CUT\": cut.sketch, \"ENGRAVE\": mark.sketch}\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\nText is engraved **outlines**, not DXF `TEXT` entities: cut and marking\ntoolchains consume geometry, and font rendering inside CAM is unreliable.\n\n## 2. Flat pattern of a generated STEP part\n\nFor a profile of a `$cad` model. The drawing imports the model and calls it,\nexactly as an assembly composes a child: importing a model never builds it,\nand the call inside the drawing's build returns the part's geometry (building\nthe part first if it is stale). The drawing's record pins the part's RESULT,\nso a geometry change in the part makes the drawing stale and a comment or\nrefactor does not; a constant imported from the part is tracked by value.\n\n```python\n\"\"\"Flat-pattern DXF drawing for <name>; geometry reused from <name>.py.\"\"\"\n\nfrom __future__ import annotations\n\nfrom cadgen import dxf, flatten\n\nfrom <name> import <name>          # a child: tracked by its result; importing never builds\n\nTHICKNESS_MM = 6.0                 # TODO: the profile face's height\n\n\nKERF = 0.0\n\n\n@dxf\ndef drawing():\n    return flatten.flat_pattern(\n        <name>(),\n        coordinate=THICKNESS_MM,   # TODO: which face plane defines the profile\n        kerf=KERF,\n    )\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n`flat_pattern` is selection + flatten + union + optional kerf offset in one\ncall. Do the steps yourself when a part needs them apart — a bracket with\nflanges on several planes selects each one, flattens each with its own\ntransform, and unions the result:\n\n```python\n@dxf\ndef drawing():\n    part = bracket()\n    faces = [\n        *flatten.planar_faces(part, normal_axis=\"z\", normal_sign=1.0,\n                              coordinate_axis=\"z\", coordinate=3.0),\n        *flatten.planar_faces(part, normal_axis=\"y\", normal_sign=-1.0,\n                              coordinate_axis=\"y\", coordinate=0.0),\n    ]\n    return flatten.union_faces(flatten.flatten_faces(faces))\n```\n\n## 3. Flat pattern of an imported STEP\n\nFor a vendor `.step` with no Python source. Read it with `cadgen.read_step`;\nlike every file a build reads, it is an input — replacing the STEP makes the\ndrawing stale on its own, with no `--force`.\n\nThe face selection is a part-specific judgment call: pick the planar face(s)\nthat define the cut profile.\n\nNever point `read_step` at a STEP this project GENERATES — that is a model\nwhose input changes every time its sibling builds. Keep vendor files in an\n`imported/` directory beside the drawing (see the CAD skill's\n`step-generation.md`), and give the drawing its own stem:\n\n```python\n\"\"\"DXF profile of vendor_panel.step.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom cadgen import dxf, flatten, read_step\n\n_STEP_PATH = Path(__file__).parent / \"imported\" / \"vendor_panel.step\"\n\n\nKERF = 0.15\n\n\n@dxf\ndef drawing():\n    part = read_step(_STEP_PATH)\n    top_z = part.bounding_box().max.Z\n    return flatten.flat_pattern(part, coordinate=top_z, kerf=KERF)\n\n\nif __name__ == \"__main__\":\n    drawing()\n```\n\n## Common additions\n\n- **Bend / fold lines**: put them on a layer whose name contains `bend`\n  (`{\"CUT\": profile, \"BEND\": fold_lines}`). Open geometry is allowed there, and\n  downstream tools classify it as bends rather than cuts.\n- **Kerf / tool-radius compensation**: `flatten.offset_profile(shape, amount)`,\n  or the `kerf=` argument of `flat_pattern`. Positive grows the profile (cut\n  outside the line), negative shrinks it. Never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are OCC operations on the real\n  faces, so a filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf\n  included. If a drawing comes out as hundreds of short `LINE`s, something fell\n  back to the sampled path — check the union inputs rather than accepting it.\n- **Why did it (not) rebuild?** `cadgen store why <drawing>.py` prints the\n  drawing's gate: its closure files, each part it called with the pinned and\n  current tree, and whether the `.dxf` on disk is the one it wrote.\n","isInternal":false,"tokens":1569,"sizeBytes":6210},{"name":"SKILL.md","path":"skills/dxf/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/dxf/SKILL.md","title":"dxf","category":"anthropic-skill","format":"markdown","content":"---\nname: dxf\ndescription: Generate, regenerate, and validate 2D DXF drawings from Python build123d sources. Use for DXF files, `.py` drawing scripts, @dxf models, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry. Open and visually review existing DXF files in CAD Viewer.\nlicense: MIT\n---\n\n# DXF generation and validation\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation and the first snapshot its headless\nbrowser; later runs reuse both.\n\n`cadgen doctor <skill-dir>` reports the installation in use and checks that it is\nthe one this skill pins, and that the CAD kernel loads; use it for installation or\nkernel load errors.\n\nDrawings are build123d geometry, so a drawing build loads the CAD kernel like a\nSTEP build does (~2.5s cold; the warm daemon absorbs it on re-runs).\n`cadgen dxf snapshot` needs no Node at all: it flattens the drawing with `ezdxf`\n(which arrives with cadgen) and paints it in the bundled headless browser.\n\n## Purpose\n\nCreate or modify 2D DXF drawings from natural-language requirements or from CAD\ngeometry, generate validated drawing artifacts, and return checked outputs. A\nDXF drawing's source of truth is a Python file named `<name>.py` defining one\nparameterless `@dxf` model function.\n\n**A drawing is a model.** It has the same wrapper, record, freshness gate and\nbuild job a `@step` part has; its one output is the `.dxf` file; it has no\ngeometry tree (nothing links to a drawing). Every run writes the sibling\n`<name>.dxf` (or the `out=` the decorator names); an unchanged source is a\nno-op; a drawing that calls a part model — `bracket()` inside its body — is\nstale whenever that part's GEOMETRY changes and current when it does not;\n`cadgen store why <drawing>.py` explains the verdict; `--force` rebuilds it\nanyway. The CAD Viewer and `dxf snapshot` read the `.dxf` file itself, so the\nfile you hand a cutting service, the file the viewer draws and the file a\nsnapshot renders are one and the same.\n\n## The contract\n\n**A `@dxf` function takes no parameters and returns build123d 2D geometry. The\nengine writes the DXF.** You never construct a document, name a file, or place\nan entity — the same division of labor `@step` has.\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import dxf\n\n\nHOLE_D = 4.5\n\n\n@dxf\ndef gasket():\n    with bd.BuildSketch() as cut:\n        bd.Rectangle(60, 40)\n        bd.Circle(HOLE_D / 2, mode=bd.Mode.SUBTRACT)\n    return cut.sketch          # bare shape -> the CUT layer\n\n\nif __name__ == \"__main__\":\n    gasket()\n```\n\n- **Bare shape** → one `CUT` layer. That is the whole contract for most drawings.\n- **`{layer: shape}`** → named layers, when the drawing genuinely has more than\n  one CAM operation (`CUT` / `ENGRAVE` / `SCORE`). A `Compound` whose children\n  are all labelled means the same thing.\n- **No parameters.** Dimensions are module constants (`HOLE_D = 4.5`) or\n  constants imported from the part the drawing derives from; a different\n  drawing is a different file.\n- **Text** is `bd.Text(...)` engraved OUTLINES on a marking layer, never a DXF\n  `TEXT` entity: cut and marking toolchains consume geometry, and font rendering\n  inside CAM is unreliable.\n- **Geometry must lie in the XY plane.** A face taken from a solid sits at that\n  solid's height; relocate it (`flatten.flatten_face(face)`, or\n  `bd.Location((0, 0, -z)) * face`). The engine REFUSES off-plane geometry rather\n  than silently writing its XY shadow.\n- **Output bytes are a function of the geometry.** Layers are sorted by name and\n  entities by geometric content, so an unchanged drawing rebuilds to an identical\n  file, cold or warm, on any machine.\n\n## The three DXF workflows\n\nCopy the full template for the applicable workflow from\n`references/generator-templates.md` when creating a new drawing.\n\n1. **Drafted from scratch** (gaskets, panels, templates, cut layouts with no 3D\n   model behind them): a `<name>.py` that builds sketches and returns them.\n\n2. **Flat pattern of a generated STEP part**: a drawing script beside the model\n   it derives from, with its OWN stem (one model per file — `bracket_drawing.py`\n   beside `bracket.py`). Import the model and call it, exactly as an assembly\n   composes a child: importing never builds, and inside the drawing's build the\n   call returns the part's geometry (building the part first if it is stale).\n\n   ```python\n   from cadgen import dxf, flatten\n   from bracket import bracket        # a child: tracked by its RESULT\n\n   KERF = 0.15\n\n\n   @dxf\n   def bracket_drawing():\n       return flatten.flat_pattern(bracket(), coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       bracket_drawing()\n   ```\n\n   The drawing's record pins the part's tree, so a part edit that changes its\n   geometry makes the drawing stale, and one that does not (a comment, a\n   refactor, a colour) leaves it current. Constants imported from the part\n   (`from bracket import THICKNESS`) are tracked by value the same way.\n\n3. **Flat pattern of an imported STEP** (a `.step`/`.stp` with no Python source):\n   read it with `cadgen.read_step` (warm from the store, the same geometry as\n   `build123d.import_step`). Like every file a build reads, it is an input:\n   replacing the vendor STEP makes the drawing stale on its own, with no `--force`.\n\n   ```python\n   from pathlib import Path\n\n   from cadgen import dxf, flatten, read_step\n\n   _HERE = Path(__file__).resolve().parent\n\n   KERF = 0.15\n\n\n   @dxf\n   def panel_flat():\n       panel = read_step(_HERE / \"imported\" / \"vendor_panel.step\")   # recorded input\n       return flatten.flat_pattern(panel, coordinate=3.0, kerf=KERF)\n\n\n   if __name__ == \"__main__\":\n       panel_flat()\n   ```\n\n   **Never read a STEP this project generates.** Reading the `.step` a `@step`\n   model writes is not a loop, it is a drawing whose input changes on every run of\n   the model: the freshness gate can never say \"current\", every build is a full\n   rebuild, and the flat pattern depends on what the last run left on disk. Keep\n   source STEPs in an `imported/` directory beside the drawing, committed like any\n   other input — input path and output path being different files is the whole\n   rule. For a STEP this project DOES generate, use workflow 2 instead: import the\n   model script and call it, which is tracked by result and never touches an\n   artifact.\n\nOne model per file is the recommendation, and a drawing gets its own script:\na file MAY declare several models — two `@dxf` drawings, or a `@dxf` beside a\n`@step` — and each is its own record, output and job (a sole model writes\n`<file>.dxf`; models sharing a file write `<function>.dxf`), but they share the\nfile's closure, so editing one rebuilds them all. A drawing composes models,\nnever the reverse: calling a `@dxf` function from a `@step` body is just its 2D\ngeometry and links nothing. The viewer catalog is artifacts-only: scripts never\nlist; the `.dxf` the run writes is the entry the viewer renders.\n\n## Use this skill when\n\nUse this skill when the user asks for DXF files, 2D drawings, profiles, outlines,\ntemplates, gaskets, panels, flat patterns, or cut layouts for laser, plasma,\nwaterjet, or CNC routing.\n\nUse `$cad` for the 3D part or assembly a DXF derives from. Use `$sendcutsend` for\nSendCutSend-specific upload preflight.\n\n## Defaults\n\nUse these defaults unless the user specifies otherwise:\n\n- Units: millimeters. The engine sets them; a drawing never declares units.\n- Geometry lives at 1:1 scale in the XY plane.\n- Cut profiles close. Open contours belong on bend/engrave/reference layers —\n  generation validation enforces this (see Validation).\n- For CAD-backed parts, derive contours from the real topology with\n  `cadgen.flatten` rather than redrawing them: `planar_faces` selects,\n  `flatten_face` lays a face into XY exactly, `union_faces` fuses, and\n  `flat_pattern` does all of it in one call. Hand-drawn parametric outlines only\n  when there is no reliable 3D topology.\n- Kerf / tool-radius compensation is `flatten.offset_profile(shape, amount)` or\n  `flat_pattern(..., kerf=...)`; never hand-offset coordinates.\n- **Curves stay curves.** The union and the offset are exact OCC operations, so a\n  filleted corner exports as an `ARC` and a hole as a `CIRCLE`, kerf included. A\n  profile that comes out as hundreds of short `LINE`s means something fell back\n  to the sampled path — investigate rather than accept it.\n- Layers carry intent: keep cut geometry and bend/fold lines on separate layers,\n  and include \"bend\" in bend-layer names so downstream tools classify them as\n  bends rather than cuts.\n- DXF layers are drawing structure, not STEP part/assembly structure.\n\n## Tool\n\n```bash\npython <drawing>.py [flags]                    # its __main__ calls the @dxf model, which writes the .dxf\ncadgen dxf snapshot <drawing.dxf> <file.png>   # render it\ncadgen store why <drawing>.py                  # why the drawing is stale or current\n```\n\n**Running the script (its `__main__` call) is the only door.** There is no\n`cadgen dxf build`: a `.dxf` has no derived state a command must materialize —\nthe file IS the product, and both the CAD Viewer and `dxf snapshot` draw it\nstraight from its own bytes. The drawing's gate makes a rebuild cheap: an unchanged\nsource whose `.dxf` still verifies and whose part children are unchanged is a\nno-op, and `--force` rebuilds anyway. The bytes are a function of the\ndrawing's GEOMETRY, so a cold run and a warm daemon worker write the same\nfile. Builds never wait on or cancel one another; a drawing that calls parts\nbuilds them in parallel like any parent.\n\nAn imported `.dxf` needs nothing at all — hand it straight to snapshot or the\nViewer.\n\nUse the active project Python interpreter; treat `python` as an interpreter\nplaceholder, and use `--help` for the full interface. Target paths resolve from\nthe command's current working directory; run from the workspace that owns the\nartifacts with cwd-relative target paths. Keep a drawing script in the same\ndirectory as the geometry it derives from, named `<name>.py`.\n\nFlags (a model script runs itself; there is no generation CLI):\n\n- `--force` — regenerate even when the recorded output is current.\n- `--verbose`, `--json`.\n\nA run answers on stdout exactly as a STEP model's does — `built DXF/plate_drawing.dxf`\nor `current DXF/plate_drawing.dxf` — with progress on stderr; `--json` makes the\nresult one JSON line (`outcome`, `document`, and `tree`, which is null for a\ndrawing) and the progress one JSON line per transition.\n\nOne script, one drawing: run each script you want built. Do not put output paths\nin the `@dxf` function's return value; `out=` on the decorator is the only\nplace a drawing names its destination (relative to the script).\n\n`cadgen dxf snapshot` draws a drawing flat, to a PNG still — the same picture\nthe CAD Viewer shows, from the same flattening, through the same drawing code:\n\n```bash\ncadgen dxf snapshot path/to/imported.dxf review.png\ncadgen dxf snapshot path/to/drawing.dxf review.png --appearance dark\n```\n\nIt takes the `.dxf` document only — a model script is refused by name (run\n`python <drawing>.py`, then snapshot the drawing it wrote). The whole drawing is\nfitted to the image and painted head on, in the pens the file declares; an\nentity with no pen of its own (ACI 7) takes the appearance's foreground on its\nbackground. The command flattens the drawing with `ezdxf` and renders it through\nthe shared snapshot CLI (`cadgen.snapshot_cli`) and the same headless browser\nruntime every rendering skill uses.\n\nOUT — the second positional — is written exactly as given, with a relative path resolved against the\ncurrent working directory. The target is deleted before the render starts and the\nfinished image is written atomically, so: reuse one name while iterating (every read\nis provably the render you just ran), name the iterations when you genuinely need to\ncompare two. Invalid request combinations fail before touching OUT; after a request is\naccepted, OUT is cleared first so a later failure leaves a missing file instead of\na stale image. A directory (`tmp/` as OUT) is the\ndon't-care case and gets a generated timestamped name inside it, printed on the\n`saved snapshot:` line.\n\nGrammar: `cadgen dxf snapshot TARGET [OUT] [flags]`. Flags: `--appearance\nlight|dark`, `--size-profile`, `--width`/`--height`, `--job`, `--debug`,\n`--json`. A drawing has no 3D scene settings: `--camera`, `--display`,\n`--mode` and `--view-labels` are not CLI flags. A job can frame a window of the\ndrawing with a plan-view `camera`\n(`{\"target\": [x, y, 0], \"position\": [x, y, d], \"up\": [0, 1, 0],\n\"orthographicHalfHeight\": h}`) and draw an `annotation` over it,\nin drawing coordinates: a Quick Edit prompt's views file is such a job, and\n`cadgen dxf snapshot --job <it>` renders the views the person saw again.\nOther camera poses, per-output cameras, scene display settings, `scale`, list mode,\noutput `label`/`viewLabel`, and `output.padding`/`viewLabels`/`tightFrame` are refused.\nA job's `output.renderScale` and `output.transparent` still apply.\nQuick Edit supplies this packet inline as JSON or by a `.snapshot.json` path; save\ninline JSON to a temporary file before using `--job`. Inspect supplied annotations\nbefore editing. Without annotations, the captured view is optional context.\n\nNo CLI inspects an existing `.dxf`. For entity/layer checks read it with `ezdxf`\ndirectly (it arrives with build123d), and `validate_dxf_file` for the drawing checks;\nreview geometry visually (see [Show the model](#show-the-model)).\n\n## Workflow\n\n1. Convert the request into a short brief: outline dimensions, holes and slots, layers, units, output path, and validation targets.\n2. Pick the workflow: drafted from scratch, flat pattern of a generated model (create and validate the 3D geometry with `$cad` first), or flat pattern of an imported STEP.\n3. Write or edit the `<name>.py` source with meaningful dimensions as named constants, reusing the model's geometry helpers instead of duplicating formulas.\n4. Run each drawing script directly (`python <drawing>.py`); do not sweep directories.\n\n```bash\npython path/to/source.py\npython path/to/source.py --force\n```\n\n5. Validate the generated DXF deterministically, then hand off and report.\n\n## Show the model\n\nShow the user each file you create or change, and any they ask to see. Snapshots and\nvalidation don't replace this.\n\n- If your tools include `cad_show` (your host may prefix it), use it with the file's\n  absolute path, and follow its description for when to call it again. `cad_view` reads\n  what the user selected; `cad_screenshot` shows you what they see. Neither is a review\n  of your own work.\n- Otherwise run the CAD Viewer, from any folder:\n\n  ```bash\n  cadgen viewer --host 127.0.0.1 --json --detach\n  ```\n\n  `--detach` returns once the viewer answers requests and leaves it running in the\n  background: always pass it, since a foreground viewer never exits (and piping its\n  output through `tail` can hide the URL for good). It starts this machine's one viewer,\n  or reuses it. Read `url` from its one JSON line (never guess the port), and for each\n  file return `url?file=<its URL-encoded absolute path>`. If it fails to launch, say so.\n\nThe viewer renders saved DXF files as read-only 2D drawings; it never runs\ngeneration scripts. Drag to pan, wheel/pinch to zoom, double-click to fit. Its\nSelect, Pencil, Shapes and Measure feed Quick Edit, whose prompt names what the person\npicked as references into the drawing by entity HANDLE: `panel.dxf#h2F` is the\nmodelspace entity whose handle is `2F` (`doc.entitydb[\"2F\"]` in ezdxf); anything a\nblock reference draws is named by its INSERT, and a dimension is one entity. The\nprompt's chip reads it as the entity's type and layer (\"Circle on CUT\"). Measurements\nare in the unit `$INSUNITS` declares (\"units\" when it declares none).\n\n## Validation\n\nValidation happens IN generation, not after: every `@dxf` build runs the drawing\nchecks on the document the engine just serialized, before anything is written, and\na build with error findings fails. The checks: cut-layer profiles must close\n(polylines, circles, or chained line/arc loops), zero-length/degenerate entities are\nrejected, exact duplicate geometry (double-cut risk) is rejected, explicitly unitless\ndocuments are rejected, and an empty modelspace is rejected. Open geometry is allowed\nonly on bend/engrave/reference-intent layers (matched by name).\n\nThe same checks run post-hoc on any existing `.dxf` file — including one that\nnever came from a generator — through `cadgen.drawing_checks`:\n\n```python\nfrom cadgen.drawing_checks import validate_dxf_file\n\nfor finding in validate_dxf_file(\"path/to/file.dxf\"):\n    print(finding.render())\n```\n\nBeyond the built-in checks, verify requested dimensions with targeted `ezdxf` reads\n(entity counts by layer, drawing extents, every dimension the user specified) against\nthe generated sibling `.dxf` (or the `out=` path when one is declared), and\nreview geometry visually in the CAD Viewer:\n\n```python\nimport ezdxf\n\ndoc = ezdxf.readfile(\"path/to/source.dxf\")\nmsp = doc.modelspace()\ncut = msp.query('*[layer==\"CUT\"]')\nholes = msp.query('CIRCLE[layer==\"CUT\"]')\n```\n\nReport only checks that actually ran.\n\n## Handoff\n\nShow every drawing you created or changed ([Show the model](#show-the-model)).\nReport any failure explicitly.\n\nFinal responses should include generated files, returned viewer links, validation\nactually run, and assumptions.\n","frontmatter":{"name":"dxf","description":"Generate, regenerate, and validate 2D DXF drawings from Python build123d sources. Use for DXF files, `.py` drawing scripts, @dxf models, 2D profiles, outlines, templates, gaskets, panels, flat patterns, laser/plasma/waterjet cut layouts, and 2D drawing exports of CAD geometry. Open and visually review existing DXF files in CAD Viewer.","license":"MIT"},"isInternal":false,"tokens":4503,"sizeBytes":18122},{"name":"openai.yaml","path":"skills/engineering-drawing/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/engineering-drawing/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"Engineering Drawing\"\n  short_description: \"Make a dimensioned engineering drawing of a CAD part, as a PDF.\"\n  default_prompt: \"Use $engineering-drawing to produce a dimensioned engineering drawing (views, dimensions, hole callouts, title block) of this part as a PDF.\"\n","isInternal":false,"tokens":66,"sizeBytes":297},{"name":"sheet-api.md","path":"skills/engineering-drawing/references/sheet-api.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/engineering-drawing/references/sheet-api.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# `cadgen.eng_drawing` reference\n\n```python\nfrom cadgen.eng_drawing import eng_drawing, Sheet\n```\n\n## Template\n\n```python\nfrom pathlib import Path\n\nimport cadgen\nfrom cadgen.eng_drawing import Sheet, eng_drawing\n# The facts the dimensions reference come from the model, not from this file.\nfrom enclosure import HOLES, L, H, W\n\n\n@eng_drawing(out=\"../PDF/enclosure_drawing.pdf\")\ndef enclosure_drawing():\n    part = cadgen.read_step(Path(__file__).parent / \"../STEP/enclosure.step\")\n    sheet = Sheet(\"A3\", title=\"ENCLOSURE\", part_number=\"ENC-001\", material=\"ABS\",\n                  revision=\"A\", general_tolerance=\"ISO 2768-m\",\n                  notes=[\"BREAK SHARP EDGES.\"])\n    # Third angle, placed from the part's size. iso=True returns FOUR views.\n    top, front, right, iso = sheet.three_views(part, iso=True)\n    top.overall()\n    top.dim((HOLES[0][0], W / 2, 0), (HOLES[2][0], W / 2, 0))   # unplaced: next free row\n    top.hole((HOLES[3][0], HOLES[3][1], H / 2), 3, depth=8, count=len(HOLES))\n    front.dim((L / 2, -W / 2, 0), (L / 2, -W / 2, H))\n    return sheet          # or [sheet_one, sheet_two] for a multi-sheet drawing\n\n\nif __name__ == \"__main__\":\n    enclosure_drawing()\n```\n\n## `@eng_drawing(out=\"<path>.pdf\")`\n\nDeclares the drawing. The function returns a `Sheet` or a list of them. Calling\nit renders one PDF at `out`, a page per sheet, and returns `[path]`.\n\n`out` is required and must name a `.pdf`; a relative path resolves against the\nscript. cadgen has no default location — where a document belongs is the\ncaller's decision. Both are checked when the module is imported, not when the\ndrawing runs.\n\nThere is no `eng_drawing` CLI verb. A `Sheet` is composed in Python — views,\ntheir placement, and dimensions between points on the part's geometry — and no\nargument list carries that, so the decorator is the whole surface.\n\n## The part\n\n`cadgen.read_step(path)` returns build123d geometry and reads warm from the\nstore. Anchor the path on the script\n(`Path(__file__).parent / \"../STEP/part.step\"`) — it resolves against the\nprocess's working directory otherwise. A live build123d shape works too; a\ndrawing takes geometry, not a file.\n\n## `Sheet(size=\"A3\", scale=1.0, title=..., part_number=\"\", material=\"\", revision=\"A\", author=\"\", units=\"mm\", projection=\"THIRD ANGLE\", notes=(), ink=\"mono\", text_height=3.5, general_tolerance=\"\", revisions=())`\n\n- `size`: `A4`, `A3`, `A2`, `A1`, `A0`, landscape. `width`/`height` in mm.\n- `scale`: drawing scale, 1 = 1:1, 0.5 = 1:2. Dimension values stay true size at\n  any scale — each view divides its measurement by its own scale.\n- `projection`: `\"THIRD ANGLE\"` (default) or `\"FIRST ANGLE\"`. The label and the\n  arrangement `three_views` produces are the same decision, so anything else is\n  refused rather than printed over the wrong layout.\n- `ink`: `\"mono\"` (default) is black ink with grey hidden lines, the printed look;\n  `\"color\"` keeps per-layer colours (red dimensions, green notes) for review.\n- `general_tolerance=\"ISO 2768-m\"`: written as note 1, \"TOLERANCES PER ISO 2768-m\n  UNLESS OTHERWISE SPECIFIED.\" Give one on any drawing a shop will quote from.\n- `revisions=[(\"A\", \"2026-09-01\", \"INITIAL RELEASE\"), ...]`: a revision table\n  (REV, DATE, DESCRIPTION) top-right inside the frame.\n- `sheet.three_views(shape, gap=None, iso=False)` → `(top, front, right)` placed\n  from the part's extents in the arrangement `projection` names, with the gap\n  sized for two rows of dimensions unless you pass one. `iso=True` adds an\n  isometric view in the free corner and returns it FOURTH — unpack four names.\n  Use `view()` with `at=` only for a custom layout.\n- `sheet.view(shape, name, at=(x, y), label=None, hidden=True, centre_marks=True, scale=None)`\n  → `View`. `scale` overrides the sheet's for this view (a pictorial drawn\n  smaller); say so in its label. `name` is one of `top`, `bottom`, `front`,\n  `back`, `left`, `right`, `iso`. The projected geometry is centred on `at`.\n\nA view whose geometry runs off the frame raises, naming the scale that fits.\n\nRunning the drawing also prints what it noticed: a dimension whose points miss\nthe view's geometry (it draws, but it measures blank paper — usually a part\nbuilt from a corner and dimensioned as if centred), and annotation printed over\nother annotation (usually one view's outermost row against the label of the\nview above it, which `three_views(gap=...)` fixes).\n\n## `View`\n\nAll points are MODEL coordinates (3-tuples; a 2-tuple means z = 0). Offsets\nare sheet millimetres.\n\n- `view.overall()`: overall width above and height left of the view, on the\n  OUTERMOST row of each side — the feature dimensions sit inside it.\n- `view.dim(p1, p2, offset=None, text=None, orientation=None, tol=None, fit=None)`:\n  linear dimension. `orientation` is `\"h\"`, `\"v\"` or None (whichever the pair\n  spans more); anything else is refused. `offset` left unset takes the next free\n  row outside the view, so unplaced dimensions never stack on each other or on\n  `overall()`. A number places the line that far from the farther of the two\n  points (not from the view's edge), and its sign picks the side; a dimension\n  between interior points then needs an offset that clears the geometry.\n  `tol=0.1` states ±0.1 as a proper tolerance (stacked, smaller text);\n  `tol=(0.05, 0.02)` states +0.05/-0.02 deviations; `fit=\"H7\"` appends an ISO\n  fit class. `text` overrides the value; `\"<>\"` inside it inserts the\n  measurement.\n- `view.hole(center, diameter, depth=None, thru=False, cbore=(dia, depth), csk=(dia, angle), thread=None, count=None, angle=45, tol=None, fit=None, label=None)`:\n  a hole callout in the standard symbols, e.g. `4× ⌀6.6 ↧12`, `⌀6 THRU`,\n  `⌴ ⌀11 ↧6.5` (counterbore), `⌵ ⌀12 × 90°` (countersink), or `M6x1 - 6H THRU`\n  when `thread` is given; `label=\"WHEEL\"` is appended; `angle` is where the\n  leader leaves the circle, in degrees from horizontal. Prefer it to\n  `diameter()` for any hole a shop drills. Every size must be a positive\n  number, and a hole is either `thru` or has a `depth`.\n- `view.diameter(center, radius, angle=45, text=None)` and\n  `view.radius(...)`: on a circular feature at `center`, plain value only.\n- `view.angle(vertex, p1, p2, offset=14)`: the angle at `vertex` between the legs\n  toward `p1` and `p2`; `offset` is the arc's distance from the vertex.\n- `view.note(text, at, offset=(10, 10))`: a leader from a model point to text.\n\n## Layers\n\n| Layer | Linetype | Weight | Holds |\n| --- | --- | --- | --- |\n| SHEET | continuous | 0.35 | frame |\n| TITLE | continuous | 0.25 | title block lines and text |\n| VISIBLE | continuous | 0.50 | visible edges |\n| HIDDEN | DASHED | 0.25 | hidden edges |\n| CENTER | CENTER | 0.18 | centre marks |\n| DIM | continuous | 0.18 | dimensions |\n| NOTES | continuous | 0.25 | notes, view labels, leaders |\n\nThey exist inside the ezdxf document the page is rendered from; the PDF carries\ntheir weights and dash patterns, not the layer names.\n","isInternal":false,"tokens":1961,"sizeBytes":6970},{"name":"SKILL.md","path":"skills/engineering-drawing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/engineering-drawing/SKILL.md","title":"engineering-drawing","category":"anthropic-skill","format":"markdown","content":"---\nname: engineering-drawing\ndescription: Make an engineering drawing of a part as a PDF - orthographic views with hidden lines and centre marks, real dimensions, hole callouts, notes and a title block on ISO sheets, all projected from the part's geometry so the drawing follows the model. Use when the user asks for a drawing, a dimensioned sheet, shop or manufacturing drawings, a print, or \"2D views of this part\".\nlicense: MIT\n---\n\n# Engineering drawing\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth.\n\nAn engineering drawing is a DOCUMENT derived from a part. `cadgen.eng_drawing`\nprojects views from the part's geometry, places dimensions between points ON\nthat geometry, finds centre marks on it, and lays the result out on an ISO\nsheet with a frame and a title block. Change the model, rerun the drawing\nscript, and the views, hidden lines and measured values move with it. Nothing\non the sheet is drawn by hand, and nothing on it is a number you typed unless\nyou chose to override a value.\n\n**The output is one PDF.** That is what a shop receives and what every\noperating system opens. No DXF is written, nothing is paired or linked, and\nthere is no CLI: the script is the interface. `$dxf` makes cut layouts and flat\npatterns, which are toolpaths, not documents; the two are different jobs.\n\n## Setup\n\nRun cadgen through [uv](https://docs.astral.sh/uv/), so this skill's commands share\none installation, and its warm build daemon, with the CAD app's server:\n\n- `cadgen` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 cadgen`\n- `python` below means `uvx --no-config --managed-python --python 3.13 --from cadgen==0.7.26 python`\n\nThe first run downloads that installation; later runs reuse it.\n\n## Workflow\n\n1. Get the part. `cadgen.read_step(Path(__file__).parent / \"../STEP/part.step\")`\n   reads the artifact a `@step` model wrote (run `python <model>.py` first); a\n   live build123d shape works just as well. The drawing documents geometry, it\n   does not build it.\n2. Write `<name>_drawing.py` beside the model, from\n   [the template](references/sheet-api.md#template). Import the model facts the\n   dimensions reference (hole pitch, overall size, wall) from the model module\n   rather than retyping them, so moving a feature moves its dimension.\n3. Place views with `sheet.three_views(part, iso=True)`, which returns FOUR\n   views: top, front and right in third angle with room for two rows of\n   dimensions, plus an isometric in the free corner. `sheet.three_views(part)`\n   returns three. Use `sheet.view(part, name, at=(x, y))` (sheet millimetres\n   from the bottom-left corner; A3 is 420 × 297, A4 is 297 × 210) only for a\n   custom layout.\n4. Dimension what a maker needs, in MODEL coordinates: overall size per view\n   (`view.overall()`), feature positions and sizes (`view.dim`, with `tol=`\n   or `fit=` where the design requires one), holes as callouts (`view.hole`:\n   thru, depth, counterbore, countersink, thread), and notes with leaders\n   (`view.note`). Leave `text` unset so the dimension is measured; give a\n   value only where the model does not define it. Leave `offset` unset too and\n   the dimension takes the next free row outside the view; pass one only to\n   place a dimension deliberately, and remember it is measured from the\n   dimension's own points, not the view's edge. Keep notes few and short; one\n   wider than the sheet's note column is refused, and `notes=` takes a LIST\n   (`notes=\"BREAK EDGES\"` is a sequence of eleven characters, so it is refused\n   too).\n5. Run `python <name>_drawing.py`. It prints the PDF it wrote, and **anything\n   it noticed while drawing** — read those first:\n   - *\"measures blank paper\"*: the dimension's model points do not land on the\n     view's geometry. Almost always the part was built from a corner and the\n     script assumed it was centred. Check the part's bounding box.\n   - *\"annotation overlaps\"*: two callouts print on top of each other, usually\n     one view's outermost dimension against the label of the view above it.\n     Pass a bigger `three_views(gap=...)` or place one with `offset=`. The text\n     is measured as the renderer draws it, glyph by glyph, so this fires on ink\n     and not on markup: a chain of toleranced dimensions is silent when the\n     values clear each other.\n\n   Steps 3 and 5 stand on their own: a sheet of views with no dimensions on it\n   yet is a drawing, and writes its PDF.\n\n   When running unattended, set `MPLCONFIGDIR` to a writable directory so\n   matplotlib's font cache does not warn.\n6. **Read the PDF** and fix what collides. Check that no dimension text sits on\n   a view, that hidden lines appear where features are behind faces, that every\n   hole has a centre mark, and that no leader crosses the part. Very small\n   dimensions (a few mm) put their value on their own extension line; dimension\n   the larger feature instead. Move `at=` or `offset=` and rerun.\n\n## What the sheet contains\n\n- Layers with meaning: `VISIBLE` (heavy outline), `HIDDEN` (dashed), `CENTER`\n  (centre marks), `DIM`, `NOTES`, `TITLE`, `SHEET` (frame), each printed at its\n  own weight — 0.5 mm down to 0.18 mm, so thick reads against thin on paper.\n  An edge is drawn once: a silhouette that the kernel returns as both visible\n  and hidden stays on `VISIBLE`. Hidden TANGENT transitions are not drawn at\n  all — a fillet running into a face, or the seam where a cylinder closes on\n  itself, marks nothing a shop can see, and drawn dashed it reads as a hidden\n  edge that is not there (on a bore, as a line down the hole's own axis).\n- Dimensions measured from the geometry, with filled arrowheads and witness\n  lines, and true-size values at any drawing scale.\n- A title block with title, part number, material, author, scale, units,\n  projection, revision, `SHEET n OF m` and the drawing function's name;\n  numbered notes above it (the general tolerance, when given, is note 1);\n  a revision table top-right when `revisions=` is given. Text longer than\n  its cell is set smaller, and cut with an ellipsis rather than overrun.\n- PDF bytes that are a function of the content: no creation date is stamped, so\n  an unchanged drawing rebuilds to an identical file.\n\n## What fails loudly\n\nThe script raises rather than writing a wrong or missing document:\n\n- Views that run off the frame, naming a scale that has been laid out and\n  verified to fit, or saying that no standard scale does.\n- A part argument the vocabulary does not define: an unknown view name, sheet\n  size or projection, `orientation=` other than `\"h\"`/`\"v\"`, a negative or zero\n  diameter, a counterbore that is not a `(diameter, depth)` pair, a `tol` that\n  is not a number or a pair, a hole that is both `thru` and given a depth,\n  `notes=` or `revisions=` given a bare string, a note wider than the sheet,\n  `angle()` legs that are collinear or zero-length, a non-finite `at=` or\n  `offset=`.\n- A missing renderer. The PDF is the drawing, so no matplotlib is a failure,\n  not a skipped half. A failed render leaves no file behind.\n- `out=` missing or not naming a `.pdf`, at import time.\n\n## Limits to state in the handoff\n\n- Tolerances come only from `tol=`, `fit=` and `general_tolerance=` that you\n  write; get the values from the design requirements or the user, never\n  invented. A dimension without one is nominal.\n- Views are orthographic projections with hidden lines from the kernel; no\n  sections, details, or auxiliary views yet. Say what a view does not show.\n- The sheet keeps callouts clear of the views it knows about, and reports\n  annotation that still prints over other annotation, but it does not check\n  annotation against line work. Read the PDF.\n","frontmatter":{"name":"engineering-drawing","description":"Make an engineering drawing of a part as a PDF - orthographic views with hidden lines and centre marks, real dimensions, hole callouts, notes and a title block on ISO sheets, all projected from the part's geometry so the drawing follows the model. Use when the user asks for a drawing, a dimensioned sheet, shop or manufacturing drawings, a print, or \"2D views of this part\".","license":"MIT"},"isInternal":false,"tokens":1940,"sizeBytes":7807},{"name":"openai.yaml","path":"skills/gcode/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/gcode/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"interface:\n  display_name: \"G-code\"\n  short_description: \"Slice models into G-code with OrcaSlicer\"\n  default_prompt: \"Use $gcode to slice this model for my printer with OrcaSlicer.\"\n","isInternal":false,"tokens":48,"sizeBytes":183},{"name":"SKILL.md","path":"skills/gcode/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/gcode/SKILL.md","title":"gcode","category":"anthropic-skill","format":"markdown","content":"---\nname: gcode\ndescription: Slice 3D models into printer-ready G-code with OrcaSlicer, the open-source slicer with built-in profiles for most FDM printers (Prusa, Bambu Lab, Creality, Voron and more). Use when the user wants an `.stl`, `.3mf` or `.obj` model sliced for their printer, as a sliced `.gcode.3mf` or plain `.gcode`, headless with OrcaSlicer's command line or by opening the model in OrcaSlicer. Never contacts a printer.\nlicense: MIT\n---\n\n# G-code\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nSlice with OrcaSlicer, headless (below) or by opening the model in the\nOrcaSlicer app for the user. The agent never contacts a printer: for a Bambu\nLab printer, hand the result to `$bambu-labs`; for any other printer, give the\nuser the `.gcode`.\n\n## Install OrcaSlicer\n\n- macOS: `brew install --cask orcaslicer`. The command line is\n  `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`.\n- Windows and Linux: install a release from\n  https://github.com/OrcaSlicer/OrcaSlicer/releases. The command is\n  `orca-slicer`; on Linux, run it from the AppImage or Flatpak.\n\nBelow, `<orca>` is that command.\n\n## Slice headless\n\nOrcaSlicer's command line (https://www.orcaslicer.com/wiki/cli/cli_mode) needs\ncomplete presets: it doesn't fill in a preset's parent settings, and it slices\nonly with a process whose compatible printers name the printer.\n`scripts/orca_presets.py` (Python 3, no dependencies) writes them from\nOrcaSlicer's presets, searching the user's own first, then OrcaSlicer's\nbuilt-in profiles.\n\n1. Find the user's printer, process and filament presets. Prefer the presets\n   the user prints with; never invent one, since a wrong bed size or\n   temperature can damage the printer.\n\n   ```bash\n   python scripts/orca_presets.py --list machine --match \"MK4S\"\n   python scripts/orca_presets.py --list process --match \"@MK4S 0.4\"\n   python scripts/orca_presets.py --list filament --match \"PLA @MK4S\"\n   ```\n\n2. Write the complete presets:\n\n   ```bash\n   python scripts/orca_presets.py --printer \"Prusa MK4S 0.4 nozzle\" \\\n     --process \"0.20mm SPEED @MK4S 0.4\" --filament \"Prusa Generic PLA @MK4S\" \\\n     --out presets\n   ```\n\n3. Slice. Give `--outputdir` an absolute path:\n\n   ```bash\n   <orca> model.stl \\\n     --load-settings \"presets/process.json;presets/printer.json\" \\\n     --load-filaments presets/filament-1.json \\\n     --arrange 1 --slice 0 \\\n     --outputdir /absolute/path/to/out --export-3mf model.gcode.3mf\n   ```\n\n   This writes `plate_1.gcode`, the plain G-code, and `model.gcode.3mf`, the\n   sliced 3MF, into that folder. The command line reads `.stl`,\n   `.3mf`, `.obj` and `.amf`; export STEP to STL or 3MF with `$cad` first.\n   Override one setting with `--<setting>=<value>`, using the setting's key\n   with hyphens for underscores, such as `--layer-height=0.16`.\n\n## Open in OrcaSlicer\n\nWhen the user would rather pick presets and slice themselves, or no preset can\nbe found, open the model in the app: `open -a OrcaSlicer model.stl` on macOS,\n`Start-Process model.stl` on Windows when OrcaSlicer opens that file type, or\n`orca-slicer model.stl` on Linux. The app also reads STEP.\n\n## Check before printing\n\n- `<orca> model.stl --info` prints the model's size; it has to fit the bed.\n- Check the slice's settings, which OrcaSlicer writes at the end of the G-code:\n\n  ```bash\n  grep -E '^; (printer_model|nozzle_diameter|filament_type|nozzle_temperature|hot_plate_temp|bed_temperature) =|printing time' out/plate_1.gcode\n  ```\n\n  Confirm they match the user's printer and material before anyone prints it.\n\n## Hand off\n\n- A Bambu Lab printer: `$bambu-labs`, with the `.gcode.3mf`.\n- Any other printer: the user prints the `.gcode` from their printer's app, its\n  web interface (PrusaLink, OctoPrint, Mainsail, Fluidd) or an SD card.\n","frontmatter":{"name":"gcode","description":"Slice 3D models into printer-ready G-code with OrcaSlicer, the open-source slicer with built-in profiles for most FDM printers (Prusa, Bambu Lab, Creality, Voron and more). Use when the user wants an `.stl`, `.3mf` or `.obj` model sliced for their printer, as a sliced `.gcode.3mf` or plain `.gcode`, headless with OrcaSlicer's command line or by opening the model in OrcaSlicer. Never contacts a printer.","license":"MIT"},"isInternal":false,"tokens":1121,"sizeBytes":3953},{"name":"openai.yaml","path":"skills/sdf/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Fagents%2Fopenai.yaml -->","isInternal":false,"tokens":66,"sizeBytes":296},{"name":"design-ledger.md","path":"skills/sdf/references/design-ledger.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/design-ledger.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fdesign-ledger.md -->","isInternal":false,"tokens":1035,"sizeBytes":4189},{"name":"examples.md","path":"skills/sdf/references/examples.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/examples.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fexamples.md -->","isInternal":false,"tokens":1357,"sizeBytes":4405},{"name":"frame-semantics.md","path":"skills/sdf/references/frame-semantics.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/frame-semantics.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fframe-semantics.md -->","isInternal":false,"tokens":884,"sizeBytes":3637},{"name":"interoperability.md","path":"skills/sdf/references/interoperability.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/interoperability.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Finteroperability.md -->","isInternal":false,"tokens":750,"sizeBytes":3611},{"name":"llm-guardrails.md","path":"skills/sdf/references/llm-guardrails.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/llm-guardrails.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fllm-guardrails.md -->","isInternal":false,"tokens":922,"sizeBytes":4534},{"name":"sdf-workflow.md","path":"skills/sdf/references/sdf-workflow.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/sdf-workflow.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsdf-workflow.md -->","isInternal":false,"tokens":900,"sizeBytes":4302},{"name":"smoke-tests.md","path":"skills/sdf/references/smoke-tests.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/smoke-tests.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fsmoke-tests.md -->","isInternal":false,"tokens":716,"sizeBytes":3371},{"name":"validation.md","path":"skills/sdf/references/validation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/references/validation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2Freferences%2Fvalidation.md -->","isInternal":false,"tokens":1819,"sizeBytes":8295},{"name":"SKILL.md","path":"skills/sdf/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sdf/SKILL.md","title":"sdf","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsdf%2FSKILL.md -->","frontmatter":{"name":"sdf","description":"SDFormat/SDF model and world authoring, validation, and simulator handoff. Use for `.sdf` files, SDFormat XML, models, worlds, links, joints, poses, frames, inertials, visual/collision geometry, mesh URIs, sensors, lights, physics, plugins, includes, Gazebo, static SDF review, or simulator-specific metadata. Do not use for signed-distance-field geometry. Open and visually review existing SDF files in CAD Viewer.","license":"MIT"},"isInternal":false,"tokens":2559,"sizeBytes":10882},{"name":"openai.yaml","path":"skills/sendcutsend/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sendcutsend/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Fagents%2Fopenai.yaml -->","isInternal":false,"tokens":52,"sizeBytes":210},{"name":"official-sources.md","path":"skills/sendcutsend/references/official-sources.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sendcutsend/references/official-sources.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Fofficial-sources.md -->","isInternal":false,"tokens":485,"sizeBytes":2212},{"name":"report-template.md","path":"skills/sendcutsend/references/report-template.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sendcutsend/references/report-template.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2Freferences%2Freport-template.md -->","isInternal":false,"tokens":632,"sizeBytes":2998},{"name":"SKILL.md","path":"skills/sendcutsend/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/sendcutsend/SKILL.md","title":"sendcutsend","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsendcutsend%2FSKILL.md -->","frontmatter":{"name":"sendcutsend","description":"Review DXF and STEP/STP uploads for SendCutSend.com orders using its ordering guide, catalog, and specs. Use only for SendCutSend.com preflight reports covering upload readiness, selected material/SKU/thickness/service availability, and service-specific checks for laser cutting, CNC routing, bending, tapping, countersinking, hardware insertion, and finishing.","license":"MIT"},"isInternal":false,"tokens":2655,"sizeBytes":12852},{"name":"openai.yaml","path":"skills/srdf/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Fagents%2Fopenai.yaml -->","isInternal":false,"tokens":74,"sizeBytes":317},{"name":"authoring-contract.md","path":"skills/srdf/references/authoring-contract.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/authoring-contract.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fauthoring-contract.md -->","isInternal":false,"tokens":1161,"sizeBytes":4611},{"name":"disabled-collisions.md","path":"skills/srdf/references/disabled-collisions.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/disabled-collisions.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fdisabled-collisions.md -->","isInternal":false,"tokens":388,"sizeBytes":1870},{"name":"end-effectors.md","path":"skills/srdf/references/end-effectors.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/end-effectors.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fend-effectors.md -->","isInternal":false,"tokens":359,"sizeBytes":1593},{"name":"planning-ledger.md","path":"skills/srdf/references/planning-ledger.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/planning-ledger.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fplanning-ledger.md -->","isInternal":false,"tokens":841,"sizeBytes":3354},{"name":"srdf-workflow.md","path":"skills/srdf/references/srdf-workflow.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/srdf-workflow.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fsrdf-workflow.md -->","isInternal":false,"tokens":674,"sizeBytes":2919},{"name":"validation.md","path":"skills/srdf/references/validation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/references/validation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2Freferences%2Fvalidation.md -->","isInternal":false,"tokens":894,"sizeBytes":3938},{"name":"SKILL.md","path":"skills/srdf/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/srdf/SKILL.md","title":"srdf","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fsrdf%2FSKILL.md -->","frontmatter":{"name":"srdf","description":"MoveIt2 SRDF authoring, validation, and planning-semantics workflow. Use when creating, editing, inspecting, or validating `.srdf` files, MoveIt planning groups, virtual joints, passive joints, end effectors, group states, disabled collisions, URDF-paired planning semantics, or SRDF handoff for live review. Use the URDF skill for robot structure and the SDF skill for simulator descriptions. Open and visually review existing SRDF files in CAD Viewer.","license":"MIT"},"isInternal":false,"tokens":2775,"sizeBytes":11944},{"name":"openai.yaml","path":"skills/step-parts/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/step-parts/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Fagents%2Fopenai.yaml -->","isInternal":false,"tokens":44,"sizeBytes":194},{"name":"step-parts-api.md","path":"skills/step-parts/references/step-parts-api.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/step-parts/references/step-parts-api.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2Freferences%2Fstep-parts-api.md -->","isInternal":false,"tokens":897,"sizeBytes":3615},{"name":"SKILL.md","path":"skills/step-parts/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/step-parts/SKILL.md","title":"step-parts","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Fstep-parts%2FSKILL.md -->","frontmatter":{"name":"step-parts","description":"Find, evaluate, and download common purchasable CAD parts from step.parts, including named off-the-shelf actuators, servos, motors, electronics boards, connectors, screws, bolts, nuts, washers, bearings, standoffs, and other catalog components. Use when Codex needs to search the hosted step.parts catalog before creating simplified placeholder geometry, resolve fuzzy part names, standards, aliases, or dimensions, choose a matching part, fetch a canonical .step file, verify checksums, or use the step.parts API/OpenAPI/catalog endpoints for standard part discovery.","license":"MIT"},"isInternal":false,"tokens":1351,"sizeBytes":6037},{"name":"openai.yaml","path":"skills/urdf/agents/openai.yaml","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/agents/openai.yaml","title":"Agents Skill","category":"anthropic-skill","format":"yaml","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Fagents%2Fopenai.yaml -->","isInternal":false,"tokens":62,"sizeBytes":285},{"name":"authoring-contract.md","path":"skills/urdf/references/authoring-contract.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/authoring-contract.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fauthoring-contract.md -->","isInternal":false,"tokens":2454,"sizeBytes":9063},{"name":"design-ledger.md","path":"skills/urdf/references/design-ledger.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/design-ledger.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fdesign-ledger.md -->","isInternal":false,"tokens":1183,"sizeBytes":5398},{"name":"frame-semantics.md","path":"skills/urdf/references/frame-semantics.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/frame-semantics.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fframe-semantics.md -->","isInternal":false,"tokens":884,"sizeBytes":3596},{"name":"inertials.md","path":"skills/urdf/references/inertials.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/inertials.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Finertials.md -->","isInternal":false,"tokens":1276,"sizeBytes":5016},{"name":"meshes.md","path":"skills/urdf/references/meshes.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/meshes.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fmeshes.md -->","isInternal":false,"tokens":899,"sizeBytes":4048},{"name":"urdf-workflow.md","path":"skills/urdf/references/urdf-workflow.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/urdf-workflow.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Furdf-workflow.md -->","isInternal":false,"tokens":1193,"sizeBytes":5779},{"name":"validation.md","path":"skills/urdf/references/validation.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/references/validation.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2Freferences%2Fvalidation.md -->","isInternal":false,"tokens":1103,"sizeBytes":4833},{"name":"SKILL.md","path":"skills/urdf/SKILL.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/skills/urdf/SKILL.md","title":"urdf","category":"anthropic-skill","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=skills%2Furdf%2FSKILL.md -->","frontmatter":{"name":"urdf","description":"URDF robot description authoring and validation. Use when creating, editing, inspecting, validating, or debugging `.urdf` files, robot links, joints, limits, inertials, visual/collision geometry, mesh references, frame conventions, or robot-description artifacts. Use the SRDF skill for MoveIt2 semantic groups and IK/path-planning semantics; use the CAD skill for STEP/STL/3MF/DXF/GLB outputs. Open and visually review existing URDF files in CAD Viewer.","license":"MIT"},"isInternal":false,"tokens":2322,"sizeBytes":9948},{"name":"marketplace.json","path":".claude-plugin/marketplace.json","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/.claude-plugin/marketplace.json","title":"earthtojake","category":"marketplace","format":"json","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fmarketplace.json -->","frontmatter":{"name":"earthtojake","owner":{"name":"earthtojake"},"interface":{"displayName":"CAD"},"metadata":{"description":"Design 3D models"},"description":"Design 3D models","version":"0.7.26","plugins":[{"name":"text-to-cad","source":"./","description":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","version":"0.7.26","author":{"name":"earthtojake"},"homepage":"https://www.texttocad.dev","repository":"https://github.com/earthtojake/text-to-cad","license":"MIT","category":"productivity","tags":["cad","robotics","fabrication","skills"]}]},"isInternal":false,"tokens":273,"sizeBytes":969},{"name":"plugin.json","path":".claude-plugin/plugin.json","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/.claude-plugin/plugin.json","title":"text-to-cad","category":"plugin-manifest","format":"json","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.claude-plugin%2Fplugin.json -->","frontmatter":{"name":"text-to-cad","version":"0.7.26","description":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","author":{"name":"earthtojake","url":"https://github.com/earthtojake"},"homepage":"https://www.texttocad.dev","documentationUrl":"https://www.texttocad.dev","supportUrl":"https://github.com/earthtojake/text-to-cad/issues/new","privacyPolicyUrl":"https://www.texttocad.dev/privacy-policy","termsOfServiceUrl":"https://www.texttocad.dev/terms-of-service","repository":"https://github.com/earthtojake/text-to-cad","license":"MIT","keywords":["cad","robotics","fabrication","skills","step","urdf","sdf","gcode"],"skills":"./skills/","mcpServers":"./claude.mcp.json"},"isInternal":false,"tokens":294,"sizeBytes":1005},{"name":"plugin.json","path":".codex-plugin/plugin.json","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/.codex-plugin/plugin.json","title":"text-to-cad","category":"plugin-manifest","format":"json","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.codex-plugin%2Fplugin.json -->","frontmatter":{"name":"text-to-cad","version":"0.7.26","description":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","author":{"name":"earthtojake","url":"https://github.com/earthtojake"},"homepage":"https://www.texttocad.dev","repository":"https://github.com/earthtojake/text-to-cad","license":"MIT","keywords":["cad","robotics","fabrication","skills","step","urdf","sdf","gcode"],"skills":"./skills/","mcpServers":"./codex.mcp.json","interface":{"displayName":"text-to-cad","shortDescription":"Design 3D models","longDescription":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","developerName":"earthtojake","composerIcon":"./.codex-plugin/logo.png","logo":"./.codex-plugin/logo.png","category":"Creativity","capabilities":["Write","Interactive"],"websiteURL":"https://www.texttocad.dev","privacyPolicyURL":"https://www.texttocad.dev/privacy-policy","termsOfServiceURL":"https://www.texttocad.dev/terms-of-service","supportURL":"https://github.com/earthtojake/text-to-cad/issues/new","defaultPrompt":["Design a 3D-printable phone stand for my desk.","Design a 50 mm aluminum L-bracket with two M5 holes.","Check my bracket for CNC machining and tell me what to change."],"brandColor":"#249DDD"}},"isInternal":false,"tokens":510,"sizeBytes":1867},{"name":"plugin.json","path":".cursor-plugin/plugin.json","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/.cursor-plugin/plugin.json","title":"text-to-cad","category":"plugin-manifest","format":"json","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=.cursor-plugin%2Fplugin.json -->","frontmatter":{"name":"text-to-cad","version":"0.7.26","description":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","author":{"name":"earthtojake","url":"https://github.com/earthtojake"},"homepage":"https://www.texttocad.dev","documentationUrl":"https://www.texttocad.dev","supportUrl":"https://github.com/earthtojake/text-to-cad/issues/new","privacyPolicyUrl":"https://www.texttocad.dev/privacy-policy","termsOfServiceUrl":"https://www.texttocad.dev/terms-of-service","repository":"https://github.com/earthtojake/text-to-cad","license":"MIT","keywords":["cad","robotics","fabrication","skills","step","urdf","sdf","gcode"],"logo":".claude-plugin/icon.png","skills":"./skills/","mcpServers":"./cursor.mcp.json"},"isInternal":false,"tokens":304,"sizeBytes":1042},{"name":"README.md","path":"packages/cadgen/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/cadgen/README.md","title":"cadgen Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcadgen%2FREADME.md -->","isInternal":false,"tokens":6680,"sizeBytes":29156},{"name":"README.md","path":"packages/core/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/core/README.md","title":"core Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fcore%2FREADME.md -->","isInternal":false,"tokens":4425,"sizeBytes":19565},{"name":"README.md","path":"packages/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/README.md","title":"packages Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2FREADME.md -->","isInternal":false,"tokens":309,"sizeBytes":1429},{"name":"README.md","path":"packages/ui/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/README.md","title":"ui Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2FREADME.md -->","isInternal":false,"tokens":4639,"sizeBytes":20248},{"name":"README.md","path":"packages/ui/src/assets/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/src/assets/README.md","title":"assets Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Fassets%2FREADME.md -->","isInternal":false,"tokens":426,"sizeBytes":1686},{"name":"README.md","path":"packages/ui/src/renderers/dxf/__fixtures__/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/src/renderers/dxf/__fixtures__/README.md","title":"__fixtures__ Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fdxf%2F__fixtures__%2FREADME.md -->","isInternal":false,"tokens":149,"sizeBytes":565},{"name":"README.md","path":"packages/ui/src/renderers/step/__fixtures__/step/README.md","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/packages/ui/src/renderers/step/__fixtures__/step/README.md","title":"step Documentation","category":"plugin-manifest","format":"markdown","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=packages%2Fui%2Fsrc%2Frenderers%2Fstep%2F__fixtures__%2Fstep%2FREADME.md -->","isInternal":false,"tokens":1136,"sizeBytes":4096},{"name":"plugin.json","path":"plugin.json","rawUrl":"https://raw.githubusercontent.com/earthtojake/text-to-cad/HEAD/plugin.json","title":"text-to-cad","category":"plugin-manifest","format":"json","content":"<!-- Skill available on-demand via /api/skills/earthtojake/text-to-cad?path=plugin.json -->","frontmatter":{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"text-to-cad","version":"0.7.26","description":"The text-to-cad plugin gives your agent local workflows for generating 3D models as STEP, GLB, STL or 3MF files. It also does design for manufacturing checks, generates engineering drawings, and connects to popular 3D printing, sheet metal and CNC fabrication services.","author":{"name":"earthtojake","url":"https://github.com/earthtojake"},"homepage":"https://www.texttocad.dev","repository":"https://github.com/earthtojake/text-to-cad","license":"MIT","keywords":["cad","robotics","fabrication","skills","step","urdf","sdf","gcode"]},"isInternal":false,"tokens":223,"sizeBytes":759}],"systemPromptSnippet":"<yakaai_skills repo=\"earthtojake/text-to-cad\">\n<!-- File: AGENTS.md (Tokens: ~4593 | Category: root-instruction) -->\n# AGENTS.md\n\nInstalling text-to-cad rather than developing it? Follow the Install section of\n[README.md](README.md).\n\nThis repo is a workbench for CAD-related agent skills. Treat `skills/` as the\nproduct and `models/` as the shared fixture/artifact area.\n\n## Branch First\n\n`main` is the only branch you develop on: the source tree, and what releases\nare cut from. Branch from `main` and open PRs against `main`; never push it\ndirectly. There is no development symlink layout — every path in the tree is the\nreal file. Installers follow `latest`, and claude.ai's directory\n`claude-plugin`: the plugin alone, one commit per release,\nwritten only by `Publish Release` once the release is on PyPI (see below); never\ncommit to them. `main` stays an installable plugin too: every manifest and MCP\nconfig lives at its root.\n\n## Release Workflow\n\nA pull request that changes `VERSION` is a release, and merging it releases it.\nNever bump during normal development work, and never pick the bump yourself: if\na request to make or ship a release does not name patch, minor, major, or an\nexact version, ask which one. A release pull request comes one of two ways:\n\n- **Its own pull request**, for a release of what `main` already has — what\n  \"make a release\" means unless the user names a pull request to carry it:\n  dispatch `Prepare Release`\n  (`gh workflow run release-prepare.yml --ref main -f bump=<patch|minor|major>`,\n  or `-f set_version=X.Y.Z`). It opens `release/X.Y.Z` with the bump and never\n  merges it.\n- **On the pull request that should carry it**: on its branch,\n  `scripts/release/bump-version.sh <patch|minor|major|X.Y.Z>` sets `VERSION` past\n  `main`'s and stamps the derived metadata and every `cadgen==` pin with it.\n\nMerge a release pull request only on the user's explicit word, once its checks\npass: the merge is the release.\n\n- `Test` runs every job for a `VERSION` change, so a release is tested whole.\n  Its Version Check vets the bump: a branch of this repository only (a fork\n  cannot release), a version past `main`'s and the latest tag, every stamp in\n  step.\n- `Publish Release` (`release-publish.yml`) runs on the merge. It finds the\n  `Test` run that recorded the merged tree as tested in full (else it runs every\n  `Test` job on it first), builds the plugin ZIP, the bundle and the `cadgen`\n  wheel and sdist, installs and exercises the wheel, uploads the pages' source\n  maps to PostHog and the wheel to PyPI, and\n  waits until PyPI's index lists it. Only then does it commit the plugin alone\n  onto the branches installers follow (`scripts/release/plugin_branch.py`):\n  `latest`, which every install command names, and `claude-plugin`, which\n  claude.ai's directory tracks. After them it deploys the docs site and tags\n  (`v<VERSION>`; releases before 0.5.0 are bare\n  `0.4.x` tags) and GitHub-Releases the release commit, with the wheel and sdist\n  that went to PyPI attached as release assets, plus the plugin ZIP that a\n  person uploads to OpenAI's plugin portal, which has no API.\n\nTo finish a run that stopped partway, dispatch `Publish Release` on `main`\n(`publish=false` leaves the GitHub Release as a draft): it publishes the commit\nthat last moved `VERSION`, if that version has no tag yet. `build-test` is the\nrehearsal branch — `Prepare Release` with `-f target=build-test` opens a bump\nagainst it, whose merge is built and installed with no PyPI, branches, docs or\ntag — and is never a release; use it only when the user explicitly asks to test\nthe pipeline.\n\nThe standalone `Deploy Docs` workflow redeploys the docs site from a ref\n(default `main`, or a release tag) without running a release.\n\ncadgen runs as ONE launch command everywhere, `uvx --no-config --managed-python\n--python 3.13 --from cadgen==<VERSION> <tool>` (`cadgen._internal.launch`): the\nplugin server configs and every cadgen skill's SKILL.md carry it, the release\nstamps every pin (`sync-version.mjs`), and `scripts/release/check-version.sh`\nand the tests assert they all equal `VERSION`. uv keeps one installation per\nrequirement and cadgen names its warm daemon after that installation, so the\nserver, every thread and every skill command share one daemon. In a checkout,\ninstall `requirements-dev.txt` (the editable cadgen) and test in agent apps with\n`scripts/install/dev_install.py`, which points both at the checkout.\n`models/` stays on `main` as plain files; nothing installs it.\n`scripts/github-workflows/check-builds.sh` enforces the shipping contract on\nevery push: no tracked symlink, no `.gitattributes` rule that rewrites files at\ncheckout or changes the archive (so no Git LFS; claude.ai's plugin directory\nrefuses them), every tracked file under 5 MiB, no skill reaching into a repo root.\nSee the Releases section in `CONTRIBUTING.md` for the full flow, the resume\npath, the rehearsal, and local/manual fallbacks.\n\n## Repo Map\n\n- `skills/`: agent skills and their references/scripts.\n- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, and the\n  [Agent Plugins](https://agent-plugins.org) standard's `plugin.json` and `mcp.json`:\n  agent plugin manifests. The repository root is the plugin package; its skills are\n  `skills/` directly. Installers take it from the `latest` branch, which\n  `scripts/release/plugin_branch.py` builds from this tree at each release.\n- `models/`: sample and durable CAD/robot-description fixtures.\n- `apps/web/`: the CAD Viewer's React client (its backend is `cadgen.viewer`).\n- `apps/mcp/`: the CAD app agent hosts render: tabs in Codex, cards in Claude Desktop (its server is `cadgen mcp`).\n- `packages/core`: `@text-to-cad/core`, shared CAD/runtime/client code without React.\n- `packages/ui`: `@text-to-cad/ui`, the shared FileViewer, renderers, controls and styles.\n- `packages/cadgen`: the published distribution — STEP/GLB/topology generation,\n  the skill CLI parsers, the CAD Viewer backend + client, and the Node/browser\n  runtimes it executes.\n- `apps/docs/`: documentation site.\n- `apps/api/`: `api.texttocad.dev`, its own Vercel project: cadgen's version feed (read from PyPI) and the CAD app's consented analytics.\n- `tests/`: root-owned test suites for skills, packages, viewer services, and\n  repo-wide policy.\n- `scripts/`: durable repo commands grouped by purpose.\n\n## Repo Rules\n\n- Boundaries and design laws live in each package's README: read\n  `packages/cadgen/README.md` (the laws), `packages/core/README.md`, `packages/ui/README.md`,\n  `apps/web/README.md`, `apps/docs/README.md` and `apps/api/README.md` before changing\n  generation, rendering, storage, layout, or public interfaces.\n- A README holds the laws; the mechanism each law constrains lives one link\n  away, and the README names the link. Read the README, then follow the one\n  link — not the tree. What exists:\n  - `packages/cadgen/`: `STORE.md` (the store contract — sectioned, with a\n    table of contents), `SNAPSHOTS.md` (snapshot `--debug` timings).\n  - `packages/core/docs/`: `render-pipeline.md`, `resource-ownership.md`,\n    `tube-deformation.md`.\n  - `packages/ui/docs/`: `settings-ui.md` (BINDING for any settings control),\n    `render-types.md`, `render-mode.md`, `lod.md`, `storage.md`, `backend.md`.\n- Ships-alone law: `packages/cadgen` (the built PyPI wheel) works in isolation\n  outside this repo, so its markdown must not refer to anything outside the\n  package — enforced by `tests/python/global/test_package_boundaries.py`.\n  Repo-development guidance for it goes in `CONTRIBUTING.md`.\n\n- The README and the docs site's homepage (its copy: `apps/docs/src/lib/content.ts`)\n  share their structure, install message and commands: change them together.\n  The README says more: the steps to install it yourself, and fuller notes under each install\n  (`apps/docs/README.md`).\n- Keep root guidance short. Put domain workflows, CLI details, and validation\n  policy in the relevant `skills/<skill>/SKILL.md` or `references/` file.\n- Keep relevant Markdown docs current when changing behavior, commands, or repo\n  layout, but do not bloat `AGENTS.md`; use it only for durable repo-level\n  rules and pointers.\n- Read `CONTRIBUTING.md` before committing, rebasing, resolving generated-file\n  conflicts, or bumping release versions.\n- A skill must not import another skill, a `skills/` root module, or a\n  repository-root module, and must not add `skills/`, the repository root, or a\n  sibling skill directory to `sys.path`, `PYTHONPATH`, `NODE_PATH`, or any other\n  runtime lookup path. Skills are independent of each other, not of everything.\n- Shared runtime comes from the **`cadgen` distribution**. A skill that uses it\n  defines `cadgen` and `python` in its SKILL.md as the launch command above,\n  pinned to `VERSION` (the release stamps every pin); no skill installs cadgen\n  any other way (no `requirements.txt` naming it), because a second installation\n  is a second daemon. Skills do not vendor it: a skill script is a thin entrypoint whose\n  parser and behaviour live in `cadgen.cli`. Playwright is a cadgen dependency and\n  the first snapshot fetches its headless browser, so no skill has a browser step. cadgen carries\n  the JavaScript it executes too (Node builders, the snapshot browser bundle,\n  the CAD Viewer client), so a skill ships no runtime of its own. Not every\n  skill needs cadgen (bambu-labs, dfam-check, dfm, gcode, sendcutsend,\n  step-parts are cadgen-free); do not add the dependency to a skill that never invokes it.\n- Keep samples and manual CAD/robot-description validation artifacts under\n  `models/`. Automated tests must not read, build or import that sample corpus:\n  generate small fixtures in fresh temporary directories or use tiny test-owned\n  fixtures, with their own cache stores and cleanup. Repo `tmp/` is fine.\n  Enforced by `tests/python/global/test_tests_are_self_contained.py`.\n- Every test runs in CI. Each test file is reached by a runner under\n  `scripts/test/`, every runner is called by a `test.yml` job on the changes that\n  can break it, and a collector that finds nothing fails the run rather than\n  reporting a group that never ran. A test no CI job runs is dead: wire it in or\n  delete it. There are no manual-only test gates. Enforced by\n  `tests/python/global/test_ci_workspace_selection.py`.\n- Tests and CI are short and succinct. Test a contract, a user flow or a fixed bug,\n  once, at the cheapest level that exercises the real path: a unit or jsdom test\n  first, a real browser (WebGL) only for what needs one. Await the condition, never\n  a fixed sleep or a wall-clock bound; a flaky test is fixed or deleted, never\n  retried. CI time is a budget: the `web` job stays within 7 minutes, and a\n  change that lengthens any job says what it costs and why in its PR.\n- Benchmarks under `scripts/bench/` are manual and their output is never\n  committed: reports, logs, profiles and screenshots go to an ignored `tmp/`.\n  Only their pure helper units run in a test runner.\n- The Python floor is `requires-python` in `packages/cadgen/pyproject.toml` and\n  nowhere else. Every cadgen source is parsed against that floor, so syntax\n  newer than it fails here rather than at `pip install` time on a user's\n  interpreter; raising the declared minimum relaxes the check automatically.\n- Reserve `scripts/` for durable repo commands. Do not write temporary,\n  one-off, or local-only helper scripts there; use `tmp/` or `/tmp` instead.\n- cadgen's packaged runtime (`_runtime/node`, `_runtime/browser`,\n  `_runtime/viewer`, and the file tracer every build loads, `_runtime/native`)\n  is BUILT, never committed: the whole directory is\n  gitignored and ships only inside the wheel. Build it with the one bundle\n  entry point, `scripts/bundle/bundle.sh`; `bundle.sh --check` builds it and\n  asserts every required output. Call `scripts/bundle/cadgen-runtime.sh`\n  directly only when debugging one stage.\n- Installs always work from `main`. A command that names no branch installs `main`\n  as it is, so every manifest, MCP config and the marketplace catalog stay at its\n  root, the catalog lists the plugin at `./`, and nothing on `main` points an\n  installer at another branch. The documented commands name `latest` instead: the\n  plugin alone, one commit per release, which `Publish Release` writes once the\n  release is on PyPI, so it installs lean (no monorepo, no npm install of the\n  workspace) and is never ahead of PyPI. `latest` is a channel, so a slower\n  `stable` can join it later. `tests/python/global/test_plugin_manifests.py`\n  holds both halves.\n- Never let a symlink reach the published tree. Agent installers disagree about\n  symlinks and one loses data silently: the Skills CLI dereferences them, Claude\n  Code preserves them, and Codex `plugin add` drops them with no error, shipping\n  a skill with missing files. `scripts/github-workflows/check-builds.sh` enforces\n  this; do not relax it.\n- The CAD Viewer is `cadgen viewer`: the server is `cadgen.viewer` (Python, in\n  `packages/cadgen`), the React client's source is `apps/web/` and its build\n  ships in the wheel at `cadgen/_runtime/viewer` (built, never committed; a\n  checkout serves `apps/web/dist`). The CAD, DXF and robot-description skills document that verb directly.\n  Nothing in `cadgen.viewer` imports the CAD kernel at module scope — the one\n  kernel action, importing a foreign STEP, is a compile job in cadgen's build\n  pool, never work the server process does.\n  Keep repo-level tooling in `scripts/`, not under `apps/web/`.\n- `packages/core` stays non-React. Shared FileViewer/renderers belong in\n  `packages/ui`; host workflow state belongs in apps. Apps never import another\n  app and shared packages never import apps. Root npm workspaces consume compiled\n  package exports; do not add source aliases or nested lockfiles. Preserve app\n  UI/UX and functionality during restructuring; changes are pure refactors.\n- Shared UI must stay platform-agnostic. Apps implement environmental effects;\n  shared features use injected capabilities and named extension slots. Before\n  extending these interfaces, read [the viewer host contract](packages/ui/docs/viewer-host.md).\n- `packages/cadgen` is the whole distribution, not just the Python: artifact\n  generation, the CLI parsers behind every skill command (`cadgen/cli`), the warm\n  build daemon (`cadgen/daemon`), and\n  the JS/SPA assets it executes (`cadgen/_runtime`, built by\n  `scripts/bundle/cadgen-runtime.sh`). Skills consume it as an\n  installed distribution.\n- Create lightweight shared Python packages under `packages/` when a helper\n  should not inherit heavier package dependencies.\n- Use path-targeted search, validation, and `git status`; avoid broad scans over\n  generated CAD artifacts unless the task requires them.\n- Treat `VERSION` as the canonical release version. Do not hand-edit duplicate\n  package, plugin, lockfile, or Python `pyproject.toml` versions;\n  `scripts/release/bump-version.sh` and `scripts/bundle/bundle.sh` stamp them\n  from the canonical version.\n\n## Environments\n\n- Prefer `./.venv/bin/python` for CAD Python work.\n- Keep new branch checkouts and git worktrees lightweight by default. Do not\n  copy `.venv/` or `models/` through `.worktreeinclude`; recreate `.venv/`\n  inside the worktree only when Python dependencies are needed for the workflow.\n- In Codex or Claude Code worktrees, prefer the skill instructions and scripts\n  under the current worktree's `skills/` directory over globally installed\n  skills or plugins from another checkout.\n- Test skills and the plugin in an agent app with\n  `scripts/install/dev_install.py <host>` (`CONTRIBUTING.md`, \"Test In Agent\n  Apps\"). One copy of the plugin per app: never the development install beside\n  the published plugin, or beside the skills installed loose.\n- Install dependencies only for the workflow being changed.\n- Do not commit `.venv/`, `node_modules/`, caches, `tmp/`, local credentials, or\n  printer config.\n\n## Checks\n\nRun the smallest path-targeted check that covers the change. Use broad wrappers\nwhen touching shared surfaces or before handoff:\n\n- Code tests: `scripts/test/test.sh` (JS, API, Python, then policy).\n- Focused runners: `scripts/test/test-js.sh`, `scripts/test/test-api.sh`, `scripts/test/test-docs.sh`,\n  `scripts/test/test-python.sh`, `scripts/test/test-global.sh`.\n  `test-python.sh` takes `--select cadgen|viewer|skills|all` and\n  `--print-weights`; `test-js.sh` takes `--select core|ui|web|mcp|all`. See\n  `scripts/README.md`.\n- In GitHub Actions, `test.yml` runs one job per concern, each only when the\n  change reaches it: `scripts/github-workflows/select_checks.py` maps every\n  changed path to the tests that read it, and the Python jobs run just those\n  files. A test that reads a new kind of path gets a rule there\n  (`CONTRIBUTING.md#ci`); an unknown path, `VERSION`, `packages/core`, the\n  lockfiles and the test machinery run everything, as does a manual dispatch.\n  Required check names stay stable.\n- Canonical release version: `scripts/release/check-version.sh`\n- Packaged runtime builds and is complete: `scripts/bundle/bundle.sh --check`\n- CAD Viewer or shared packages: build exports with `npm run build:packages`,\n  then `npm --prefix packages/core test`, `npm --prefix packages/ui test`,\n  `npm --prefix apps/web run test`, `npm --prefix apps/web run build`.\n  The Viewer is two languages and `npm run test` covers only the client — the\n  backend's suite is `tests/python/packages/cadgen/viewer`, run by\n  `scripts/test/test-python.sh`. Touching `cadgen/viewer/` means running that.\n- Docs site: `npm --prefix apps/docs run check`\n- API (`api.texttocad.dev`): `npm --prefix apps/api test`\n- Targeted Python tests: `./.venv/bin/python -m unittest <changed test paths>`\n\nWhen a task changes what the bundlers consume, run `scripts/bundle/bundle.sh`\nand confirm the change lands in the built runtime. There is nothing to commit:\n`_runtime/` is gitignored end to end, so what a reviewer reads is the source and\nwhat a user gets is the wheel the release builds from it.\n\n## CAD Viewer\n\nThe app-facing playbook lives in `apps/web/README.md`: launcher contract\n(reuse, ports, `--new`), dev vs prod, and the catalog/link-verification\ngotchas. The repo-side half — the lightweight-worktree recipe and\nroot workspace dependencies — lives in `CONTRIBUTING.md` under \"Viewer Development\nIn This Repo\". Read them before starting, stopping, or debugging a Viewer.\nNever stop an instance you did not start; packaged-runtime checks go\nthrough `scripts/bundle/bundle.sh`.\n\n## Git\n\nNo Git LFS, and every file under 5 MiB: the repository root is the plugin, and\nclaude.ai's plugin directory refuses files a filter rewrites at checkout, so\nheavyweight media stays out of the tree. Local hooks live in `.githooks` and\ndelegate build checks through `scripts/git-hooks/pre-commit`.\n\n<!-- File: apps/web/skills/smui/SKILL.md (Tokens: ~721 | Category: anthropic-skill) -->\n# Shared UI styling for the CAD Viewer\n\nThe CAD Viewer is a Vite and Tailwind v4 host of `@text-to-cad/ui`. Shared\ncomponents and CAD controls live in that package; the web app owns its top bar\nand browser-specific composition. In a repository checkout, read\n`packages/ui/README.md` before changing a\nshared control.\n\nThe package migration is a pure refactor. Preserve the current appearance and\ninteraction behavior of each app: typography, spacing, borders, surfaces,\nresponsive layout, loading states and saved preferences. Moving a component\ndoes not authorize a redesign. Compare changes against the existing app in\nboth color schemes and at narrow widths.\n\n## Tokens and ownership\n\n- Canonical tokens live in\n  `packages/ui/src/styles/tokens.css`.\n  The current palette is neutral, with light and dark variants and the existing\n  radius scale. Reuse those values and semantic utilities such as\n  `bg-background`, `text-muted-foreground`, `border-border` and\n  `text-destructive`.\n- The web entry stylesheet,\n  [globals.css](../../src/client/styles/globals.css), imports\n  `@text-to-cad/ui/tokens.css` and `@text-to-cad/ui/styles.css`. Shared utility classes\n  and assets are built by the UI package; apps do not scan another package's\n  source or duplicate its token definitions.\n- The host supplies the color scheme and applies its existing `.dark` behavior.\n  Keep the CAD scene theme separate from host appearance, using the existing\n  preference adapter and shared renderer contract.\n- Preserve the current font, text case, radius, opacity and shadows of the\n  affected component. There is no additional Nord palette, glass-surface rule,\n  uppercase-label rule or monospace requirement to apply.\n- Import reusable controls through `@text-to-cad/ui/primitives/*` and `cn` through\n  `@text-to-cad/ui/utils`. The class-merging helper lives in\n  `packages/ui/src/lib/utils.js`.\n  Keep existing host-specific controls when their appearance or behavior differs.\n\n## Controls and interaction\n\nUse the existing shared primitives and variants. Preserve their keyboard\nnavigation, focus styling, labels, disabled states and pointer behavior when\nmoving or wrapping them. Icon-only controls need an accessible name; use the\nexisting Lucide icons and tooltips. Status and error text must convey meaning\nwithout relying only on color.\n\nFor rows in a CAD file's panels (its own panel and Display), follow\n`packages/ui/docs/settings-ui.md`. It defines the\nexisting row types, control widths, units, section structure and accessible\nlabel conventions. Reuse those CAD controls inside the renderer instead of\nadding a second settings layout.\n\nKeep loading, empty and error states in their existing positions. Preserve\nfocus and dismissal behavior for menus and dialogs, and retain reduced-motion\nhandling for animated indicators. A visual cleanup must not remove feedback,\nchange a control's availability or alter selection and navigation behavior.\n\n## Validation\n\nCheck the affected app and shared component in light and dark modes, at the\nnormal and narrow layouts, and with keyboard as well as pointer input. For CAD\nchanges, include the file's panels, toolbar, loading/error and preview\nstates; the `packages/ui/docs/render-types.md` cover\ncross-format and theme behavior. Rebuild shared package output before checking\nan app that consumes it, following the package README. Do not use a migration\nor documentation update to change the styling baseline.\n\n<!-- File: packages/core/docs/render-pipeline.md (Tokens: ~10199 | Category: anthropic-skill) -->\n# Render Pipeline\n\n`@text-to-cad/core` exposes a staged render pipeline for shared viewer, docs, and generated\nsnapshot browser-runtime work. A STEP document goes:\n\n```js\nconst source = await loadSource(input, sourceOptions);\nconst model = buildModel(THREE, source, modelOptions);\nconst viewport = renderModel(THREE, model, viewportOptions);\nconst result = await captureModel(viewport, captureOptions);\n```\n\nEvery other file family (GLB, STL, 3MF, URDF, SRDF, SDF) is drawn by ITS OWN scene\nbuilder, the one the viewer's renderer for it calls too: the snapshot page loads the\nfile with that family's loader, builds the scene with that family's builder, dresses it\nin the look the viewer's viewport would, and hands it to the same `renderModel` and\n`captureModel` (`common/headlessScene.js`). No family is flattened into mesh data for a\nrender.\n\nThe stages keep ownership narrow:\n\n- `loadSource` owns STEP source and sidecar loading plus file-kind validation.\n- `buildModel` owns the CAD object graph, records, selection, clipping,\n  materials, topology/display edges, and STEP parameter effects.\n- A family builder owns its scene: `createGlbScene` (`lib/render/glbScene.js`),\n  `buildMeshScene` (`lib/render/meshScene.js`), `createRobotScene`\n  (`lib/urdf/robotScene.js`). Each returns the scene contract of\n  `lib/viewer/sceneContract.js`, and each scene gives a sibling (`createSibling()`): a\n  second scene of the same file with its own objects, materials and look over the first's\n  geometry (`lib/render/sharedGeometry.js`), which the viewer's Preview draws beside\n  its editor. A GLB's sibling is a SkeletonUtils clone wearing copies of the file's authored\n  materials, kept before any look, with a sibling `document` for its own routine; a robot's\n  has its own links and pose, and its own vertex colours. Ownership is\n  [resource-ownership §1](resource-ownership.md#1-shared-ownership-and-disposal).\n- `renderModel` owns renderer, scene, camera, lighting, background, floor,\n  framing, resizing, and render loop concerns.\n- `captureModel` owns deterministic snapshot outputs without filesystem writes.\n\nThe CAD skill's Python snapshot CLI remains responsible for job parsing, path\nresolution, Playwright routing, and writing returned outputs to disk.\n\n## Modules\n\n### `common/viewSettings.js` and `common/sceneSettings.js`\n\n`normalizeViewSettings(display)` is the strict public schema shared with the\nsnapshot CLI. It preserves omitted groups and inherited appearance, defaulting\nonly `mode` to `solid`. A file's modes, in order, are `solid`, `shaded`, `parts`,\n`xray`, `hidden-line`, and `wireframe` (`VIEW_MODE_VALUES`); `grid` is a boolean\nswitch every mode draws, off unless said. Appearance, camera, clip and exploded are\nshared. A mode is a look at its own defaults: it has no settings of its own, and\nanything else (a per-mode tree, a style group) fails validation.\n\n```js\n{ mode: \"parts\", grid: true, camera: { projection: \"orthographic\", focalLength: 50 }, clip: { axis: \"z\", at: 4 } }\n```\n\nPreview's photographic look (Render) is no mode: it is asked for on its own, by the\nviewer's Preview and a snapshot's `preview`, over the same display. Its own settings\n(`PreviewSettings`: `parts`, `lighting`, `background`, `floor`) are the viewer's,\nkept beside the display, never in it (`normalizePreviewSettings`,\n`resolvePreviewSettings`, `previewSettingsChanged`); a snapshot draws Preview at its\ndefaults. `readViewSettings` and `readPreviewSettings` read a stored record setting\nby setting, leaving out what this build cannot read, without rewriting it.\n\n`resolveViewSettings` returns the established renderer groups. Solid is shaded with\nthe model's visible CAD edges where the view offers them (`edges`); Shaded is the same\nsurfaces without; Parts is Solid with the by-part palette. X-ray uses 0.22 surface\nopacity and all edges; Hidden line uses depth-only surfaces and visible edges; Wireframe\nhas no fill and all edges, both lighter on a dark backdrop. With `preview: true` it\nresolves Render whatever the mode: perspective, no edges, no grid and the photographic\nstudio, from `previewSettings`. The modes use the shared camera, orthographic by default.\nThe grid is the same measuring grid in every mode (`viewGridDefaults`). The view's\ncapabilities are explicit injected lists (`viewFeaturesForKind`): a STEP every mode, any\nother 3D file Solid, and Parts where it has several parts (`scenePartCount`); a saved mode\na view does not offer resolves as Solid without rewriting storage. Surface opacity\nmultiplies authored opacity; colours remain presentation settings, never model material\nedits.\n\nClip keeps coordinates below its plane by default (`invert: false`); Reverse (in Clip's axis dropdown)\nkeeps coordinates above it. `clip.at` is the plane's coordinate on its axis in millimetres;\nomitting it cuts through the middle of the authored bounds. A disabled clip cuts nothing.\n`clip.partColors` defaults to true. Caps use each part's normal base material color,\nwithout selection/hover tints or opacity; false uses uniform amber (`0xe9a451`).\nCaps stay opaque even for transparent parts, and outer materials are unchanged.\nEach intersected part has a winding pair and an opaque fill, so holes remain empty;\nthe fill (as wide as the model on the plane) writes the stencil back to 0 wherever it\ncovers, so no part needs a stencil clear of the whole buffer after it. Cap geometry/materials are owned by Clip; stencil\ngeometry is borrowed from the display meshes. Generic scene looks preserve\n`mesh.userData.cadBaseColor` before highlight ink; STEP records use `baseColor`\nand normal effect colors. A mesh with several materials uses its first material\nas its cap color; texture and vertex-color variation are not sampled.\n\n`resolveViewSceneSettings({display, preview, previewSettings, camera, appearance, quality})` is the public\nViewer/snapshot scene-policy boundary (`preview`: Render over the display, as above). Its `view` is the full grouped state;\n`display` is the historical internal draw policy, including `surfaces` and\nedge color/visibility. `camera` combines the top-level pose with the grouped\nlens. Public top-level and per-output cameras contain pose/framing only;\nprojection and focal length belong to `display.camera`. Focal length ranges\nfrom 20 to 200 mm. `orthographicHalfHeight` is the positive pre-zoom vertical\nhalf-extent and may be retained while perspective is active.\n\nThe resolver's `render.enabled` means lighting, background or floor needs the\nstudio scene helper. `render.configuration.lighting.enabled` independently\ncontrols photographic illumination and PMREM allocation. `theme` retains the\nneutral workbench recipe whenever photographic lighting is disabled, including\nwhen a custom background or floor is present. Consumers build CAD models with\n`surfaceSettings: resolved.view.surfaces` and the resolved edges; they must not\nreapply historical mode-owned edge forcing. `resolveSceneSettings()` remains an\ninternal compatibility resolver for older low-level consumers.\n\nBackground opacity follows the ordinary alpha convention: 0 is transparent and\n1 is opaque. Fractional alpha is carried by the renderer clear color with\n`scene.background = null`, so viewer checkerboards and PNG captures agree. Over that\nclear the studio draws its backdrop (`studioBackdrop.js`), first of everything and\nwriting no depth: the background color as a seamless sweep, lifted toward white\nbehind the middle of the frame, shaded toward the corners and, by how far the view\nray looks up, above the horizon (`PHOTOGRAPHIC_STUDIO_BACKDROP`; the shade is a larger\nshare of a dark color than of a light one). It writes the clear's alpha, premultiplied\nas three's clear is, so a half-transparent background keeps its gradient; at alpha 0 it\nis not drawn, and a caller that renders with `scene.background` taken away and a clear\nalpha of 0 (a thumbnail) gets nothing of it. The floor's mirrored draw leaves it out.\nThe floor is independent of background alpha. Render's stands at authored\nZ=0 (`placement: \"origin\"`, `RENDER_FLOOR_PLACEMENT`, the Render recipe's backdrop\ndefault too); `placement: \"lowest\"` follows the current model minimum without moving\ngeometry or lighting. Its double-sided plane uses one transparency pass,\nkeeping geometry below the origin visible. Depth fitting includes the actual floor elevation;\northographic views can use a signed near plane when the floor crosses the eye.\n\nLighting quality is `preview` or `final`, mapping to standard/high scene policy.\nThe viewer passes `lightingQuality: \"preview\"`; the CLI baseline is `final`.\nThat environment default also feeds Preview's default comparison and does not pin a saved\noverride. An explicit Preview `lighting.quality` wins in the viewer; a snapshot's `preview`\nis always `final`.\nFinal uses the bounded L3 mesh rung, a 0.25px viewport target, 4096px spotlight\nshadows, a 512px procedural environment and 2x snapshot capture, and a still view\nin the viewer converges over 64 samples of the studio's frame (Preview: 32). Technical\n`quality.tessellation` and `output.renderScale` remain explicit overrides.\nPNG output retains requested dimensions by resampling the full drawing buffer.\n\nThe two studios use one physical Render pipeline. A neutral HDR key card, a rear\nfill card, a side bounce card and the studio's fixed soft sources\n(`PHOTOGRAPHIC_STUDIO_PANELS`: a large overhead softbox and two tall strip boxes\nabout 75 degrees either side of the default camera) hang in a studio sweep and\ngenerate a procedural PMREM for authored PBR reflections; one aligned,\nmodel-scaled SpotLight supplies direct illumination and PCF shadows. Softbox\nsize changes every source's area and bounded shadow softness while keeping each\nsource's total flux stable. Fill sets both fill cards' radiance as a fraction of\nthe key card's. Rotation moves the direct light and `scene.environmentRotation`\ntogether around CAD Z. An overhead side key reveals depth; the rear fill keeps\nhorizontal reflections readable and the bounce, low on the key's far side,\nlifts the faces the key cannot reach (an iso view's right-hand side). The\nsoftbox and strips are brightest along their centres and fall off toward their\nedges, and the sweep (`PHOTOGRAPHIC_STUDIO_ROOM_RADIANCE`: 0.14 overhead, 0.07\nat the horizon and a light floor's 0.3 below, easing between them) is dimmer\nthan they are, so polished and satin metal show defined gradients and highlight\nlines between darker gaps — what reads as metal rather than grey plastic — and\ndark glossy parts keep their color. Nothing is a void: a face turned away from\nevery source keeps a soft fill. Environment radiance and direct illumination\nshare a calibrated zero-EV lighting budget. Khronos PBR Neutral tone mapping is\nfixed; `toneMappingExposure` is `2 ** exposure`. Light and dark differ only in\ndefault backdrop color.\n\n#### The studio's frame\n\nRender's lighting is drawn through the studio's frame (`studioFrame.js`), which\n`applyPhotographicStudio` keeps as `state.frame` while its lighting is on, and the viewer\nand the snapshot both draw the scene through. Three draws the scene as before, but into an\nXR-flagged target of the frame's (`isXRRenderTarget`), which three draws exactly as the\ncanvas, tone mapping and colour space included, so no material compiles a second program;\nits depth is kept. Small full-screen passes then add:\n\n- **Ambient occlusion.** Ground-truth AO (GTAO, after XeGTAO) from that depth alone, at half\n  the drawing buffer's resolution: the horizon either side of each pixel along three screen\n  directions, four steps each, within a radius of 0.1 of the model's rest radius (never more\n  than a fifth of the view's height). Every sample is read at a full-resolution pixel's\n  centre; a depth read from one pixel and placed at another turned sloped faces into\n  staircases of false horizons. A depth-aware blur smooths it, and the composite upsamples it\n  by depth, so it never bleeds across a silhouette, and darkens the scene's colour by it in\n  linear light (`power` 2). The floor writes no depth, so it keeps its own contact shadow;\n  the background is never darkened. This is what reads as a photograph rather than a\n  render: the seam between a cover and its housing, the space between stacked plates, a\n  bolt head on its plate, the inside of a bore.\n- **Convergence.** A still view is the average of `samples` samples, each blended into a\n  half-float accumulation at `1/(n+1)` and presented with a level of dither. Every sample but\n  the first moves the camera a fraction of a pixel (Halton 2, 3), so edges and fine\n  highlights are anti-aliased; turns the occlusion's directions (interleaved gradient noise\n  per sample), so it converges smooth; and moves the key light to a point of its softbox's\n  face (`sampleKey`: R2 over the key card's face, `PHOTOGRAPHIC_STUDIO_SOFT_SHADOW.spread`\n  of it, scaled by Size), so the key's shadows become a softbox's: crisp where a part meets\n  what it shades and soft away from it, on the model and, through the floor shadow's\n  composite, on the floor. A shadow pass that only moved the key holds the floor shadow's\n  heights (`holdHeights`) and re-composites its key shadow alone.\n\nThe frame's caller says whether a frame is `still`: nothing but, perhaps, the camera changed\nsince the last. A still frame is the next sample unless the camera has moved the model by\nmore than `STUDIO_FRAME_DRIFT_PIXELS` (0.25) on screen since the picture's first sample, so the\ntail of a damped orbit still converges; anything else (a camera that moved, a pose, a\nroutine's frame, a setting, a resize) starts again from the first sample, which is a whole\npicture of its own: the key at rest, the occlusion denoised in space. `render` answers whether\nsamples are still owed (`refining`); the caller asks for the next frame while they are, and\nfor none once the picture has converged. A moving view therefore pays for one draw and the\nsmall passes a frame; the samples only while it rests.\n\nMeasured at 3024 × 1964 on an M1 Max over 0.6 M and 3 M triangles: a moving frame\n3.1 → 4.5 ms and 5.0 → 6.6 ms, a sample 5.2 and 7.8 ms, all 32 samples back to back 155\nand 240 ms (in the viewer, a sample a display frame: about half a second). Memory is the scene target (8-bit colour and depth-stencil, multisampled as\nthe canvas is: four samples, two on a canvas of pixel ratio 2 or more), its resolve, a\nhalf-float accumulation and two half-resolution occlusion targets: about 200 MB at that\nsize, released with the studio. Software rendering has no frame: three's single draw, as\nbefore, whose key casts no shadow to soften.\n\n`applyPhotographicStudio(THREE, runtime, configuration, options)` owns the\nsynchronous light, ground and renderer state and updates those objects in place.\n`disposePhotographicStudio(runtime)` releases only those objects, the studio's frame\nincluded (`applyPhotographicStudio`'s `frame`: false for none, or its `samples` and\n`multisample`). The caller\nseparately owns the PMREM returned by\n`createEnvironmentResource(renderer, configuration, {size})` — synchronous GPU\nwork, returned directly rather than as a promise — assigns its\ntexture to `scene.environment`, and releases it through\n`disposeEnvironmentResource()`. `environmentResourceIdentity()` includes\nsoftbox size, fill, and PMREM resolution; it excludes rotation so rotating the\nrig is a live scene update rather than an environment rebuild. A resource also\nexposes `readPixels()` for transferring its half-float PMREM from worker WebGL.\nThe interactive UI prepares and caches these maps off the main thread; snapshot\nrendering can keep the synchronous caller-owned path.\n\nThe floor has no edge (`PHOTOGRAPHIC_STUDIO_FLOOR_FADE`): from 1.6 times the model's\nreach (the larger of its rest radius and its footprint's half-diagonal) it fades out\nto its inscribed circle at 6 times it, where the two-triangle plane ends, so a low or\nwide view shows no horizon line and no corner, only the backdrop. The fade is applied\nto the floor's alpha after its reflection is laid over it, so the reflection fades with\nit; it is squared, so its tail is long even foreshortened at a grazing angle. Camera\nfitting keeps `PHOTOGRAPHIC_STUDIO_STAGE_RADIUS_MULTIPLIER` as far-plane padding, far past\nthat rim. Near-plane fitting includes the visible floor as well as the model.\n\nThe physical floor is mostly self-lit backdrop color and receives no shadow\nmap; its shadow is a contact layer drawn over it (`studioContactShadow.js`):\ndarkest where the model meets the floor, a broad occlusion fading with height,\nand the key's cast shadow crisp near the model and softening into a wide, light\npenumbra away from it. The layer is one texture over a square fitted to the\nrest placement (`PHOTOGRAPHIC_STUDIO_CONTACT_SHADOW`), as deep as the floor is\nopaque, and hidden while the studio's lighting is off; a floor at zero opacity\nis not drawn, so it bakes nothing. It is baked in two steps: the heights, a\ndepth pass of the shadow casters seen up through the floor by a probe light\nthat is never added to the scene, and the composite, three small full-screen\npasses over those heights and the key's own shadow map. Both are due only when\nthe key's shadows re-render: a sentinel in the studio, a shadow caster of zero\narea that every shadow pass and the main pass draw (it rasterizes nothing in\neither), notices that pass. The composite runs in that frame, so the key's cast\nshadow on the floor follows every frame a model moves in. The heights do too,\nunless the caller passes a `heightInterval` (`applyPhotographicStudio`'s\n`contactShadow` option): then a scene that keeps changing re-measures them at\nmost that often, and once more when it stops, in a frame the layer asks for\n(`requestFrame`), so what is shown at rest is exact and only the contact\ndarkening lags while a model moves. The interactive viewer re-renders shadows\nonly for a change that can alter them, never for a camera move or a highlight,\nso it bakes nothing on those frames; the snapshot renderer, which renders\nshadows every frame and passes no interval, bakes both steps every frame. A\nruntime that renders in software (`softwareRendering`) gets no contact layer:\nits key casts no shadow either. A transparent legacy backdrop's floor stays a\n`ShadowMaterial` catcher of the key's shadow.\n\nThe floor and its shadow layer are dithered: each adds up to one 8-bit level of\nnoise as it is drawn (the floor's divided by its alpha, so its blend keeps it\nwhole; the layer's drawn premultiplied, so the blend adds exactly that noise over\nwhatever floor is under it). A dark floor's shading spans only a few levels of\nthe canvas, and rounding drew it as wavy contour bands; the noise is too fine to\nsee and leaves every average colour where it was. A snapshot drawn at a render\nscale above 1 averages that scale squared of drawn pixels into each one it keeps,\nwhich quietened the noise to a fraction of a level and let the rounding band\nagain: it passes its render scale as `applyPhotographicStudio`'s `ditherScale`,\nwhich spreads both noises that much wider (`syncDitherScale`). The viewer keeps\n1, and at 1 nothing is defined: its shaders are the ones they always were.\n\nThe floor's finish is `backdrop.groundFinish` (`PHOTOGRAPHIC_STUDIO_FLOOR_FINISHES`):\n`matte`, the default, or `glossy`, a glossier surface that also reflects the model\n(`studioFloorReflection.js`). Before each frame of the scene (its `onBeforeRender`)\nthe scene is drawn again from the camera mirrored in the floor, at half the\ncanvas's resolution; three small passes fade and soften it with each reflected\npoint's height (crisp where the model meets the floor), none of them sampling the\ntarget it draws into, a draw WebGL refuses, and the floor lays it over\nitself with a Fresnel weight, in display colour, so a light floor shows it as a\ndark one does. The mirrored draw runs when the camera moved or the frame re-renders\nshadows (it renders them, and the frame keeps them); a frame for a highlight keeps\nthe last reflection and catches up within 400 ms; a snapshot draws it every frame.\nIt runs before the frame, at the frame's own render depth and into a target three\ntreats as the canvas (`isXRRenderTarget`): drawn nested in the frame, or as an\nordinary target, it made every lit material rebuild its program key twice a frame,\nwhich cost more than the draw. A matte floor allocates none of it; turning the\nfloor matte, the studio's lighting off or the floor off releases it.\n\nEnvironment radiance and direct illumination are calibrated together at zero EV\nacross colored assemblies, gray mechanical models, and authored metal/plastic\nfinishes.\n\n#### What Render's public contract does NOT have\n\nNo global material, no color grading, no arbitrary lights, no floor physics, no\nglow controls. Adding one of these is a change to the law in the package\nREADME, not a new option here.\n\nMaterials reaching that scene stay authored. STEP package material channels are\ninputs to the rig: assigned sparse materials use roughness 0.42, metalness 0.03,\nclearcoat 0, clearcoat roughness 0.26, and opacity 1; an absent base color\nretains the STEP color, and authored opacity multiplies its source alpha.\nA GLB, a mesh file and a robot reach the studio as the scene their viewer renderer\nbuilds. A GLB is its native glTF hierarchy, so its textures and PBR channels stay\nattached and Render wears them exactly as authored; an STL, a 3MF and a robot author no\nfinish, so they wear the studio's surface over their colours (a 3MF's object colours, a\nrobot description's colour, else its link mesh's own).\n\nSnapshots use the same grouped display contract. Photographic lighting supports\nonly the `view` capture mode. Camera,\nselection, clipping, exploded view, guides, part colour, animation, video,\nkinematics, robot joints, tessellation quality, per-output cameras and output\nsizing remain composable.\n\nPhotographic lighting creates its WebGL renderer with\n`logarithmicDepthBuffer: false`. Three's logarithmic depth shader path does not\nproduce usable contact shadows. Render callers fit ordinary-depth near/far\nplanes to current model bounds with `fitCameraDepthToBounds(camera, bounds)`.\nThe interactive viewer uses this fitted conventional depth for every preset so\nsettings can enable shadows without replacing its renderer/canvas. The fit also\nuses visible records for closeups, the floor's actual elevation, and the grid's\nplane and bounds independently of Floor. Otherwise the subject's near plane\nclips foreground guides, or the far plane truncates their finite span. A\nperspective camera passes its `pivot` (the point it looks at): its near plane\nnever comes nearer than 1/256 of the pivot's depth, so a closeup the fit cannot\nmeasure (the camera inside a part's own box, or a routine deforming what it\nmeasures) keeps its depth resolution instead of making close surfaces fight.\nSnapshots draw every preset the same way: ordinary depth, fitted to each output\nwith the same inputs (its placed records, the studio floor's elevation, the\ndrawn grid's span and the camera's target as pivot). A logarithmic buffer would\nalso lose the instanced CAD edges, which write no logarithmic depth: under a\nperspective camera every edge failed its depth test against the surfaces.\n\n### `common/source.js`\n\n```js\nimport {\n  loadSource,\n  stepParameterRuntime\n} from \"@text-to-cad/core/common/source.js\";\n```\n\n`loadSource(input, options)` returns a normalized STEP render source:\n\n```js\n{\n  kind,\n  meshData,\n  selectorRuntime,\n  displayEdgeRuntime,\n  stepParameterSource,\n  resolved,\n  url,\n  glbUrl,\n  cadPath\n}\n```\n\nAccepted input fields:\n\n- `kind`: `step` or `stp`, or inferred from a URL. A GLB, STL, 3MF, URDF, SRDF\n  or SDF is refused by name: it is drawn by its own scene builder\n  (`common/headlessScene.js`), never flattened into mesh data here.\n- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.\n- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.\n- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.\n- A caller that passes a `resolved` packet together with any source URL must also pass\n  `resolved.inputPath`. Render asset caches are page-lifetime, so a resolved job has to name the\n  source its cache entries belong to; a resolved job without `inputPath` is rejected rather than\n  cached under an unidentified source. Callers with no `resolved` packet (the interactive viewer and\n  the docs hero renderer) render one source per page and need nothing.\n- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller\n  already owns sidecar loading.\n- `kinematics`: pose values for the model's kinematics — a declared preset name,\n  or `{dof: value}`. Same spelling as the `--kinematics` flag, the snapshot job\n  key and the sidecar section.\n- `stepParameterUrl` or `resolved.stepParameterUrl`: model sidecar\n  (`.step.json`) URL, whose `kinematics` section is compiled here.\n- `quality.tessellation`: explicit STEP tolerances in every mode. When\n  omitted, `preview: true` selects the bounded final mesh rung.\n\nSTEP-only options are rejected for non-STEP sources. The old shared `params`\nfield is rejected, and so is the retired `stepParameters` spelling; use\n`kinematics`.\n\nUse `stepParameterRuntime(stepParameterSource)` to turn the loaded parameter\nsource into the runtime object `buildModel` accepts.\n\n### `common/cadScene.js`\n\n```js\nimport {\n  buildModel,\n  fitCameraToModel\n} from \"@text-to-cad/core/common/cadScene.js\";\n```\n\n`buildModel(THREE, source, settings)` returns a model API:\n\n```js\n{\n  source,\n  meshData,\n  root,\n  modelGroup,\n  edgesGroup,\n  displayRecords,\n  records,\n  bounds,\n  restBounds,\n  radius,\n  runtime,\n  update(nextSettings),\n  dispose()\n}\n```\n\n`source` can be a `loadSource()` result or raw mesh data. The model owns the\nThree.js object graph and its mutable state.\n\n`bounds` follows the live pose — what lighting, the floor, shadows and clipping\nneed. `restBounds` is the same model at its ZERO pose, before a parameter, mate\nor animation frame moved a record, and it is what a camera fit is grounded on so\nthat posing a model never re-frames it. A package that declares its whole box\n(assembly.json's `bbox`, `declaredBounds` in its composition) rests in that box,\nas the scene contract has it: the viewer frames and grounds it, and a snapshot\nsizes its ground and radiates an exploded view from it. A family scene carries\nits own (`restBounds` in the scene contract): a robot's is every joint at its\ndefault.\n\nCommon settings:\n\n- `theme`: normalized or raw CAD scene settings. Render passes none and supplies\n  `materialSettings` instead.\n- `displayMode`: `shaded`, `shaded_edges`, `transparent`, `hidden_edges`,\n  `hidden_lines_removed`, `unshaded`, or `wireframe`.\n- `edgeSettings`: display-owned CAD edge style. Themes do not own edges.\n- `materialOverrides`: sparse explicit PBR overrides; authored PBR otherwise\n  wins over studio material fallbacks.\n- `scale`/`sceneScale`: CAD or robot scene scale.\n- `selection`: internal selection/filtering state. `focus` and `hide`\n  filter rendered parts before records are built. Viewer-only fields such as\n  `selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.\n- `clip`: normalized clip-plane settings.\n- `stepParameters`: compiled kinematics runtime object, from\n  `stepParameterRuntime()`.\n- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.\n- `renderPartsIndividually`: build per-part records instead of a whole mesh.\n- `edgeRendering`: declarative edge rendering configuration.\n\nDeclarative screen-space edge rendering:\n\n```js\nbuildModel(THREE, source, {\n  edgeRendering: {\n    mode: \"screen-space\",\n    Line2,\n    LineGeometry,\n    LineSegments2,\n    LineSegmentsGeometry,\n    LineMaterial,\n    wireframeEdgeColor: \"#111827\"\n  }\n});\n```\n\nThe model keeps screen-space line material bookkeeping internal through\n`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.\nCallers should not provide callbacks that create edge objects.\n\n`model.update(nextSettings)` merges mutable settings, rebuilds geometry only\nwhen needed, reapplies material/selection/clip/STEP parameter state, and returns\nthe same model API. `model.dispose()` releases model-owned scene objects and\nSTEP parameter cleanup hooks.\n\nSource colors: a GLB's material base colors and its `COLOR_0` vertex attribute\nboth count as source colors (`lib/render/glbMeshData.js`). A vertex-colored\npart renders on a white base so the ramp shows unmixed — an FEA result or scan\nheatmap keeps its colors even when the file declares no materials at all — and\n`overrideSourceColors` in material settings replaces both kinds with display\nfills.\nPackage components carry no vertex colors; their coloring is the descriptor's\noccurrence/component/face colors.\n\n`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic\ncamera framing helper used by interactive rendering.\n\n### Display modes, CAD edges and geometry sharing\n\nInternal draw modes are `shaded`, `shaded_edges`, `transparent`,\n`hidden_edges`, `hidden_lines_removed`, `unshaded`, and `wireframe`. Public\npresets resolve to those implementation details plus independent surface and\nedge settings; never feed a public preset into `normalizeDisplayMode()`. Normal CAD\nkeeps authored albedo, opacity and finish. Workbench PBR channels are fallbacks;\na small neutral reflection environment makes authored metals readable without\nloading the photographic studio.\n`resolveDisplayMaterialSettings()` applies the shared Original, Single color\nand Color by part policy without app state.\n\n**Scene geometry is the tessellator's INDEXED output.** A surf component's\n`meshData` shares the tessellation's vertex, normal and index buffers by\nreference (a decoded `.tess` cache entry is copied out of its one entry\nbuffer) and is never expanded per triangle corner.\n\n**CAD edges are not a surface shader.** `surfMeshData.js` emits indexed line\nsegments (`cadEdgePositions` + `cadEdgeIndices` + `cadEdgeClassRanges`, from\nthe same tessellation's boundary polylines, ~1.5 bytes per surface triangle)\nand `cadScene.js` draws them as ONE instanced screen-space line draw per\ncomponent (`cadEdgeInstances.js`): the instances are every (segment,\noccurrence) pair, decoded in the vertex shader from a per-component segment\ntexture (32 B per drawn segment, cached on the component) and a per-set\ninstance texture (128 B per occurrence: matrix, colour, opacity, visibility,\nhighlight).\n\n**Every mode draws the same edges, Wireframe included.** Wireframe is the CAD\nedges through the model with no fill, never the tessellation's triangles: a\nswitch to it reuses the segment textures (or, for a baked mesh without segments,\nthe one cached edge geometry per component, keyed by no mode) that Solid, X-ray\nand Hidden line draw, so it builds nothing per triangle. It once built three's\n`WireframeGeometry` over every triangle on the main thread: 10 s and +1 GB on a\n437 MB STEP, for a picture that read as a solid blob.\n\n`cadInk.js` fixes one dark model-edge palette and nominal widths per class:\nfeature 1, tangent 0.65, seam 0.8, and degenerate 0 (hidden). Public\n`display.edges` exposes enabled, visible/all visibility and color; widths stay\nrenderer-owned and the default color preserves the restrained class palette. Grid settings keep\n`enabled` plus optional `color`, `opacity` and `density`; omission follows the\nappearance's graph-paper ink (`GRID_LINE_DEFAULTS` in `stageGrid.js`), while an\nexplicit color applies to every minor and major line but never the red X and\ngreen Y axes. Viewer and snapshots draw the same grid (`createGridMesh`): its\nstep, majors and long distance fade are in `packages/ui/docs/render-mode.md`.\nThe Axes line is the blue Z axis.\nAppearance updates preserve model lighting, materials, geometry,\nsegment textures and occurrence slots; only the canvas and guides adapt.\nAn edge color edit keeps them too: `model.update` recolors the live edge draws\nin place.\n\nA thickness is a FULL width in DEVICE pixels — every line shader normalises its\nextrusion by the drawing buffer, never the CSS size, and the fragment stage\nfilters a box with a symmetric ±0.75 px kernel. Integrated coverage equals the\nnominal width even for subpixel lines, and zero width has zero coverage. Both\nline paths share the filter; antialiasing does not inflate thin lines or depend\non the framebuffer sample count.\n\nPer-occurrence highlight, dim, hide, focus, exploded placement and selection\nare slots in that texture, written by the same record passes\n(`applyDisplayRecordTransform`, `applyPartVisualState`,\n`syncRecordEdgeMaterials`) that drive a plain line object; highlighted\noccurrences draw in a second pass at the highlight render order. A deformed\ntube leaves its slot for private screen-space lines, one per drawn class, that\nbend with the surface and preserve the same class weights and colours.\nBasic-only hosts use separate per-class materials too, so appearance changes\nretain private edge geometry and cannot recolour another scene's component.\nGPU cost per component: two textures, one 4-vertex quad, two materials, one\ndraw call (+1 while any occurrence is highlighted).\n\n**One upload per component.** Geometry built from a shared component is cached\non the component object (`part.sourceMesh`), never on the composed package\n`meshData`: a package is re-composed on every progressive publish and LOD swap,\nand every occurrence, publish and swap reuses the one upload. A publish of the\nsame model reaches the live scene through `api.update({ source })`, which\nreconciles records by occurrence id — records already on screen keep their\nmesh, materials, visual and deformation state and BVH; only new occurrences are\nbuilt and only departed ones disposed (ownership and disposal:\n[resource-ownership.md](resource-ownership.md)). Effects that change vertex\npositions or normals acquire writable attributes before deforming them;\nmaterial refreshes leave component data unchanged. This keeps large assemblies\nfrom duplicating these buffers for display. Assemblies keep geometry in their\ncomponent buffers; they allocate no combined copy of all positions, normals and\nindices. Rendering visits the placed components directly, and\nthe Viewer accepts that component geometry.\n\n### `common/renderModel.js`\n\nUse this module for interactive browser canvases, including the docs hero.\n\n```js\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n```\n\n`renderModel(THREE, model, options)` returns an interactive viewport API:\n\n```js\n{\n  THREE,\n  model,\n  renderer,\n  scene,\n  camera,\n  ready,\n  resize(),\n  render(),\n  start(),\n  stop(),\n  capturePng(),\n  dispose()\n}\n```\n\nCommon options:\n\n- `canvas`: existing canvas for the renderer.\n- `hostElement`/`container`: element used for responsive sizing.\n- `renderer`: caller-owned renderer. If omitted, one is created.\n- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are\n  created.\n- `theme`/`themeSettings`: background and lighting settings.\n- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,\n  `logarithmicDepthBuffer`, `shadows`: renderer controls.\n- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.\n- `pixelRatio`, `maxPixelRatio`: output density controls.\n- `autoResize`: set `false` to disable `ResizeObserver`.\n- `autoStart`: set `true` to start an animation loop.\n- `autoRender`: set `false` to prevent the initial render.\n- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.\n- `disposeModel`: set `false` when the caller will dispose the model.\n\n`dispose()` stops animation, disconnects resize observation, removes the model\nroot from the scene, and disposes the created renderer/model unless ownership\nwas explicitly retained by options.\n\n### `common/renderMeshScene.js`\n\nUse this module for deterministic headless snapshot rendering.\n\n```js\nimport {\n  renderJobContext,\n  modelOptionsForRenderJob,\n  renderModel,\n  captureModel,\n  renderMeshJob\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n```\n\n`renderJobContext(meshData, job)` resolves the shared scene contract plus\nsnapshot-owned output policy: display, camera, quality, scene scale, outputs,\nSTEP topology edge visibility, and warnings. Studio scene quality is Preview's final\nwhen the job asks for `preview`; `job.quality` contains technical tessellation only. A\nfamily scene passes `{ bounds, partCount, explodeParts }` for `meshData`; its job's kind\nresolves the view features (`viewFeaturesForKind`): anything but a STEP has no edges,\nParts only with several parts, and Explode only for a GLB of several.\n\n`modelOptionsForRenderJob(context, job)` converts that policy into\n`buildModel()` settings.\n\nSnapshot `renderModel(THREE, model, { job, context })` holds either a CAD model from\n`buildModel` or a family scene wrapped by `headlessSceneModel`; only a CAD model has a\nruntime, so only it takes the exploded view, topology edges and screen-space line\nwidths. It returns a headless viewport:\n\n```js\n{\n  THREE,\n  model,\n  scene,\n  renderer,\n  orthographicCamera,\n  perspectiveCamera,\n  context,\n  sceneBuildStarted,\n  ready,\n  dispose()\n}\n```\n\nThis `renderModel` is intentionally separate from `common/renderModel.js`.\nRender is drawn through the studio's frame, every sample of it at once\n(`renderStill`: `PHOTOGRAPHIC_STUDIO_FRAME.stillSamples`, 32, re-rendering shadows only for a\nsample that moved the key), with no MSAA of its own: the snapshot supersamples at its render\nscale. A video's frame and a software renderer draw one sample (`snapshotStudioSamples`).\nIt uses snapshot sizing, studio environment, the shared stage floor/glow/shadow,\nmodel-scaled lights with a fitted shadow frustum, canonical camera projection,\nand deterministic renderer settings. Automatic perspective cameras fit the\ncurrent visible vertices to `output.padding` when `output.tightFrame` is true;\notherwise they fit the model bounds. An explicit camera position is never\nreframed. Video capture fits its precomputed sequence-union bounds once so the\ncamera does not breathe between frames. As in the viewer, a still's studio floor\nand its contact shadow stay sized and centred on the rest placement however the\nmodel is posed or exploded (`groundBounds`), and an exploded view radiates from\nthat rest box.\n\n`captureModel(viewport, { job })` returns data only:\n\n- `mode: \"view\"`: PNG data URLs in `outputs`.\n- `mode: \"list\"`: part list and bounds. A CAD model lists its composed part\n  occurrences; a family scene lists what it drew, one row per mesh, in its scene\n  graph's order (a robot's scene attaches a link's meshes before the joints it\n  carries, so its rows run down the tree from the root).\n\nIt does not write files. The CAD skill snapshot CLI writes the returned data to\ndisk. Consumers use compiled `@text-to-cad/core` exports; generated snapshot browser assets\nbundle this entrypoint into cadgen's packaged runtime (`cadgen/_runtime/browser`).\n\n`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,\nbuilds a model, renders/captures it, and disposes owned resources.\n\n### `common/headlessScene.js`\n\nThe snapshot page's half of \"one scene builder per file family\". `headlessSceneFamily(job)`\nanswers, from the job's resolved kind, the family whose loader and builder draw it (GLB;\nSTL and 3MF; URDF, SRDF and SDF), or null for a STEP document. A family is\n`{ load(job, { resources }), build(THREE, loaded, job), release(loaded) }`, each a call to\nthe module the viewer's renderer calls: `loadRenderGlbDocument` + `createGlbScene`,\n`loadRenderMeshByUrl` + `buildMeshScene`, `loadRobot` + `createRobotScene`. A robot is\nposed as it is built, at `robotOpeningPose` (every joint's default, then an SRDF's `home`\ngroup state: where the viewer opens it) with the job's `jointValues` on top.\n\n`headlessSceneModel(THREE, scene, headlessSceneDress(context.sceneSettings))` dresses the\nscene exactly as the viewer's viewport dresses one it adopts, with the look\n`resolveSceneSurfaceLook` resolves (`common/sceneSettings.js`, which the viewport calls\ntoo) and shadows while the lighting is on, and exposes what `renderModel` and\n`captureModel` read of any model: `root`, `bounds`, `restBounds` (what sizes the ground),\n`keepsAuthoredFinish`, the drawn meshes for the tight frame, `listParts()` and `dispose()`.\nThe still's tight frame reads a skinned or morphed mesh where the GPU draws it.\n\n## Kinematics\n\nTwo names, two things, and they are not interchangeable:\n\n* `kinematics` is the POSE INPUT — what `loadSource()` and the snapshot job\n  packet take. A declared preset name, or direct DOF values:\n\n  ```json\n  { \"drive\": 180, \"ringVisible\": false }\n  ```\n\n  Animation envelopes (`animate`, `fps`, `durationSeconds`, `duration`, `loop`)\n  are retired and throw: a still renders one frame at the given values.\n\n* `stepParameters` is the compiled RUNTIME OBJECT that `buildModel()` takes,\n  produced by `stepParameterRuntime(source.stepParameterSource)`.\n\n`common/stepParameters.js` validates the pose values against the loaded\ndefinition and normalizes defaults.\n`loadSource()` uses it to populate `source.stepParameterSource`; callers then\npass `stepParameterRuntime()` into `buildModel()`.\n\n## Examples\n\nInteractive viewer/docs usage:\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource, stepParameterRuntime } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport { renderModel } from \"@text-to-cad/core/common/renderModel.js\";\n\nconst source = await loadSource({\n  kind: \"step\",\n  glbUrl: \"/models/.part.step.glb\",\n  sourceSidecarUrl: \"/models/part.step.json\",\n  cadPath: \"models/part.step\",\n  kinematics: { drive: 180 }\n});\n\nconst model = buildModel(THREE, source, {\n  theme,\n  displayMode: \"shaded_edges\",\n  edgeSettings,\n  stepParameters: stepParameterRuntime(source.stepParameterSource)\n});\n\nconst viewport = renderModel(THREE, model, {\n  canvas,\n  hostElement: canvas.parentElement,\n  theme,\n  autoStart: true\n});\n```\n\nHeadless snapshot usage (a STEP document; `headlessRenderEntry.js` routes every other\nfamily through `common/headlessScene.js` into the same `renderModel`/`captureModel`):\n\n```js\nimport * as THREE from \"three\";\nimport { loadSource } from \"@text-to-cad/core/common/source.js\";\nimport { buildModel } from \"@text-to-cad/core/common/cadScene.js\";\nimport {\n  captureModel,\n  modelOptionsForRenderJob,\n  renderJobContext,\n  renderModel\n} from \"@text-to-cad/core/common/renderMeshScene.js\";\n\nconst source = await loadSource(job);\nconst context = renderJobContext(source.meshData, job);\nconst model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));\nconst viewport = renderModel(THREE, model, { job, context });\n\ntry {\n  const result = await captureModel(viewport, { job });\n  // Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.\n} finally {\n  viewport.dispose();\n}\n```\n\n## Ownership Rules\n\n- Do not write files from shared render APIs. Return data to the owning CLI or\n  application layer.\n- Do not expose object-construction callbacks for edges. Use declarative\n  `edgeRendering`.\n- Keep STEP-only options explicitly STEP-named and reject them for non-STEP\n  sources.\n- Dispose viewports and models that you create.\n- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new\n  shared render code instead of loading assets or constructing render scenes\n  inline.\n- Draw a GLB, a mesh file or a robot with its family's scene builder, in the viewer\n  and in a snapshot alike. Never give one host its own way to draw a family:\n  `scripts/test/check-kit-boundaries.mjs` (`checkSharedSceneBuilders`) refuses it.\n\n<!-- File: packages/core/docs/resource-ownership.md (Tokens: ~3046 | Category: anthropic-skill) -->\n# Resource ownership and reuse\n\nHow the shared render code owns, shares, reuses and releases the expensive\nthings: component geometry, GPU buffers, BVH accelerators, tessellation\nworkers and cache reservations. Workspace resource reads and their cache\ngenerations are specified in [workspace services](workspace-resources.md).\n\nThe one-sentence laws are in [the package README](../README.md#the-laws-that-live-here);\nthis file is the mechanism each of them constrains. Nothing here may be traded\naway for speed: **reuse is an optimization, never a change in exact geometry,\npersistent cache identity or occurrence identity.**\n\n| § | Covers |\n|---|---|\n| [1](#1-shared-ownership-and-disposal) | More than one scene owner, and who disposes |\n| [2](#2-the-demand-boundary) | Selectors and raycast accelerators built only when asked for |\n| [3](#3-reuse-without-changing-geometry) | Recomposition, instancing, culling, material pass keys |\n| [4](#4-tessellation-workers) | One request per worker, pool growth, reclamation, charges |\n| [5](#5-browser-mesh-cache-admission) | Probe, admit, verify, adopt — and strict reads |\n\n## 1. Shared ownership and disposal\n\nComponent geometry and edge textures can have more than one scene owner; only\nthe last release disposes shared GPU/BVH state.\n\n- Full scene disposal includes host-reparented groups and records attached by\n  an interrupted reconciliation.\n- Cleanup retires completed ownership steps. A thrown disposal remains\n  retryable and cannot release another scene's share twice.\n- Failed initial construction cleans its partial scene before throwing, or\n  transfers the still-owned scene with an explicit cleanup error for retry.\n- Disposable resource admission never changes exact geometry or persistent\n  cache identity.\n- A scene's sibling (`createSibling()` of `lib/render/glbScene.js`,\n  `lib/render/meshScene.js`, `lib/urdf/robotScene.js`) draws over the source's\n  geometry through `lib/render/sharedGeometry.js`: `shareGeometry` gives it the\n  source's index and attribute objects, so one renderer drawing both scenes uploads\n  each buffer once, and copies only what the sibling rewrites for itself (a robot's\n  vertex colours). `releaseGeometry` lets go of the shared attributes before\n  disposing, so only what the sibling owns is freed. The source stays the owner and\n  is disposed after its siblings. A second STEP scene filled from the same published\n  mesh shares component geometry through the owner counts above.\n- `createCadWebGlRenderer` (`common/webglRenderer.js`) takes the `canvas` it draws\n  on (an `OffscreenCanvas`), so one renderer, and one upload of each resource, can\n  serve several views; disposing it and losing its context are its caller's.\n\n## 2. The demand boundary\n\nRender-only loads do not construct selector topology until it is requested,\nand viewport refinement keeps that boundary.\n\n- Unused components replace only display arrays. A component with active\n  topology replaces its selectors at the same concrete tessellation before\n  publishing new triangles.\n- Raycast accelerators may be deferred until a ray reaches a component's\n  local bounds. The first ray uses exact stock intersection; surface builds\n  enter a single worker queue in idle time and are shared by occurrences.\n- Admission covers private position/index copies, worker scratch, and the\n  result before creating an isolate. Display arrays are never transferred.\n  Each worker ends with its reservation; only serialized BVH nodes and the\n  indirect triangle permutation return. Byte accounting includes both the\n  packed BVH nodes and that indirect permutation.\n- Failed or pending builds keep stock picking. A result must match the\n  geometry's attributes, arrays, versions, groups, draw range and live\n  ownership; the last geometry release cancels queued or active work.\n- Deformation runs before the bounds test.\n- The merged edge-pick `LineSegments` (`edgePickRaycast.js`) keeps its one\n  buffer and gains a table of boxes over contiguous 32-segment runs (and\n  1,024-segment groups), built on the first edge ray (O(segments), a tenth of\n  the index buffer) and keyed by the proxy's position array. A ray runs\n  three's own segment test over only the segments whose boxes (group, run,\n  then the segment's own) it passes within the pick threshold of, widened by\n  1e-4 of the ray's reach, far above three's rounding, so the hits and their\n  order are the full scan's. A sync whose proxy only grew (every earlier\n  index and position bit-identical) keeps the built boxes and boxes only the\n  appended segments; any other change builds afresh on the next edge ray.\n\n## 3. Reuse without changing geometry\n\n### Recomposition\n\nRecomposition may take its previous result when the descriptor and component\ninputs are immutable. It shares unchanged occurrence rows and unchanged tree\nmetadata across detail swaps; changed triangle ranges, bounds, placements and\nappearance still produce the corresponding new records. Its private weak\nownership metadata never enters saved geometry or cache identity. Complete\nsame-file revisions can also seed from the prior composition.\n\nDetached occurrence snapshots detect in-place descriptor or material edits;\nonly rows proven to belong to the current composition may skip scene work. A\nstatic delta requires the same last-applied render context and no dynamic\npose, animation or clip. Changed components update their own records, while\nunchanged occurrence geometry and selectors retain their owners. Arbitrary\nmutable caller meshes keep normal reconciliation.\n\n### Selector composition\n\nAn assembly's selector runtime is composed from one part per placed occurrence\n(`buildSelectorCompositionPart`, `createSelectorRuntimeComposer` in\n`lib/selectors/runtime.js`). A part keeps its rows, its references as last\nplaced and the bundle's own proxy buffers with the placement transform — never\na placed copy of the proxy or lookup maps. The composer re-places a part's\nreferences only when its offsets move, and a composition that begins with the\nprevious one extends that result instead of rebuilding it. The result is value\nfor value the whole-runtime `composeSelectorRuntimes` result for the same\ncomponents in the same order (`runtimeComposer.test.js`). An edge's chain\nendpoints are read lazily from the bundle's unplaced positions, placed and\nrounded as the placed proxy stores them; copies of a reference carry the\naccessor rather than reading it.\n\n### Instancing and culling\n\nRepeated compatible opaque surfaces share instanced draws and retain\noccurrence identity; mirrors, transparency and deformation use explicit\nfallback paths.\n\nDetail publications retain compatible surface instance sets and their original\noccurrence slots. Only changed membership or render passes replace those sets;\nselected, hidden and deformed occurrences keep inactive slots until eligible\nagain. Transform passes reuse each mesh's matrix while observing mutable\nsource transforms and effect matrices on every update.\n\nSurface instance groups use aggregate frustum bounds, invalidated by instance\nmatrix and slot changes. Transformed component boxes and conservative parent\naffine padding keep boundary-crossing geometry eligible, including shear.\nCulling changes draw submission only; occurrence slots and picking remain\nintact. Screen-space edge instances retain their separate drawing policy.\n\n### Material pass keys\n\nMaterial pass keys reuse serialized strings only after comparing their current\nscalar values, emission state, render order and clipping planes. Direct\nmaterial and plane mutations remain observable; custom values use ordinary\nserialization. Reapplying material settings preserves unchanged shader programs\nand owned emissive colors. Colour and PBR uniforms still update on every pass;\nfeature changes, vertex-colour mode and transparency invalidate the appropriate\nprogram. Reflection intensity belongs to the shared pass key. A distinct\nnonblack emissive channel, or emission over vertex colours, uses the ordinary\nmaterial so the instance colour cannot tint that channel.\n\nClients that apply pose, selection or material changes directly to display\nrecords finish with `scene.syncSurfaceInstances()`; this uses the same\nreconciliation as a source update. Clip-only viewer updates also synchronize\nthe shared surface material.\n\n### Revision reuse\n\nCanonical geometry trees contain no surface-producer selection. A runtime view\nbinds each component's opaque surface input to a concrete immutable SURF\nobject, and render/selector reuse requires that exact binding plus the lossless\ntolerance pair and payload version. Other snapshot source scopes remain\nisolated. Placement and appearance belong to each tree's occurrence\ncomposition. Viewport L0 is explicitly coarse; L1 preserves the canonical\ndefault mesh options and key. **Changing viewport detail never changes export\ndefaults.**\n\n## 4. Tessellation workers\n\nEach tessellation worker runs one request at a time; excess requests wait on\nthe client. Render sessions lease that shared scheduler. Releasing one owner\nnever cancels another owner; the final release retires remaining work and\nworkers. Idle pressure reclamation preserves active and queued consumers.\n\n- Aborting synchronous work replaces only its worker, preserving other\n  callers. A failed worker request reports an error instead of retrying\n  expensive tessellation on the UI thread. Inline execution is reserved for\n  environments where workers cannot start.\n- A pool starts with one isolate and grows only for ready concurrent requests,\n  or when a package load finds its first components cached: it starts the\n  isolates its lanes will decode on (`prewarmSurfWorkerPool`, at most the\n  lanes' count), so their start overlaps the batch read of those bodies instead\n  of following it. Sequential viewport refinement reuses that isolate until the\n  drain becomes idle; it does not create a maximum-size pool for each component.\n- Pressure reclamation may release idle worker slots while active and queued\n  consumers keep their work. Each live slot retains its own highest completed\n  request estimate, including handled failures; reclamation or replacement\n  removes that slot's charge.\n- Memory estimates stay on the client. They do not enter worker messages or\n  cache keys, and RAM hits add no worker charge.\n\n## 5. Browser mesh-cache admission\n\nBrowser mesh-cache reads start with a bounded metadata probe. The client\nadmits the encoded object and conservative decoded size before fetching a\nbody, binds that fetch to the probed object digest and byte limit, then\nverifies the v4 header and content address before adoption.\n\nA package's open reads its cache in groups, as a snapshot does: one probe for a\nchunk of components and one TESB read for a batch of their bodies, each\ngrowing from the loader's first publish (eight components) to the server's\nbounds (256 keys, 32 MiB), in load order (`packageBatchReads.js`). A client\nwhose transport caps one reply declares a lower ceiling for a batch's bytes\n(`createCadClient({ maxBatchBytes })`, which its cache reports as\n`batchMaxBytes`): the CAD app's tunnel declares 4 MiB, the most one of its\nreplies carries. A ceiling never raises the server's bound\n(`tessBatchMaxBytes`), and a body larger than the ceiling is read alone, as\nevery body once was; that transport carries it a range at a time and hands the\nprovider the whole, which verifies its digest as it does any body's. A batch's\nframed bytes are charged to the Viewer envelope before it is read and released\nonce its last component has taken its body; each component's decode is still\nadmitted on its own before it runs, and an entry the batch could not read or\nverify is that component's strict-read miss alone. The cold components'\nsurfaces resolve up to 64 to a `/__cad/surfaces` request, each the moment its\nown row is ready.\n\nA validated warm entry carries the full surface-object provenance, so\nrendering does not need the SURF object or its derivation index to remain\npresent. `createHttpTessellationCacheProvider` takes an `origin` for hosts\nwhose cache is not on the page's own origin. TESB body groups remain bounded\nat 32 MiB; the Node export provider uses the same immutable `objects/` and\n`index/mesh/` layout as Python.\n\nA caller admitted using a cache probe can request a strict read: a missing or\ninvalid body reports a typed cache miss before tessellation starts. The viewer\nreleases that reservation and probes another cached tier or resolves the exact\nsurface under fresh cold-work admission. **Cache loss never silently turns a\ncheap decoded-mesh request into unbudgeted surface tessellation.**\n\nA client lazily owns one `createTessellationCache({provider})`, whose\n`createSession({signal})` method lends cancellable views to render sessions.\nNo module-global provider can be swapped by another client. Closing a view aborts\nits reads and prevents late results from enqueueing writes; writes already\nadmitted to the client's byte-bounded queue survive file switches. Closing the\nclient disposes the owner, aborts every view and discards remaining queued\nwrites. Viewer write-backs drain in batches with bounded concurrency: after a\nquiet interval, no later than two seconds after a batch's first entry however\nbusy the load, and at once when a batch reaches its byte bound; an entry is\nturned away only while the writer is still busy with the full batch before it.\nSo the queue holds at most two batches of encoded bodies: the one being\nwritten, at most 32 MiB (`TESS_BATCH_MAX_BYTES`, the default `maxPendingBytes`)\nplus the entry that reached the bound, and the one being collected meanwhile,\nat most 32 MiB: about 64 MiB plus one entry in all (`memoryStats()`).\nSnapshot jobs flush and dispose their own cache after their complete source is\nloaded. Decoded component meshes retain their existing page-wide\ncontent-addressed LRU; a cache view does not retain an additional geometry copy.\n\nImported STEP products without faces keep their occurrence identity. A SURF\nthat holds only wires has no loops to measure, so its scale and box come from\nits edge curves. A SURF with no faces or edges tessellates to empty arrays, a\nzero-size box at the origin and a positive minimum scale. Either way the same\nv4 validation applies. That box is only cache metadata: only triangles are\ndrawn, so composition gives an occurrence without them no bounds, and it\ncannot change the assembly's framing or hide the real parts.\n\n<!-- File: packages/core/docs/tube-deformation.md (Tokens: ~1726 | Category: anthropic-skill) -->\n# Flexible tube animation\n\n`m.get(target).deformTube({rest, path, twistDeg=0, maxSegmentLength=1, braid?})`\nanimates a continuous tube or hollow sheath already present as a swept STEP\nbody. It deforms the original surface and its CAD edge lines in the same shared\npass used by CAD Viewer and snapshots. It does not create a replacement rope or\nchange the STEP artifact. Subsequent rigid occurrence transforms act on the\ndeformed result. The paths use assembly coordinates before those transforms.\n\n```js\nconst rest = {\n  normal: [0, 0, 1],\n  segments: [{kind: 'line', start: [0, 0, 0], end: [10, 0, 0]}]\n};\nconst bent = {\n  normal: [0, 0, 1],\n  segments: [{\n    kind: 'arc', center: [0, 5, 0], axis: [0, 0, 1],\n    start: [0, 0, 0], sweepDeg: 90\n  }]\n};\n// Inside an embedded animation clip update(t, m):\nm.get('tendon').deformTube({rest, path: bent, twistDeg: 360 * t});\n```\n\nEach path is `{normal, segments}`. `normal` is REQUIRED on both the rest and\nthe posed path: it seeds the transverse frame at the first point (projected\nperpendicular to the first tangent; later frames use parallel transport). There\nis no default — a guessed seed would flip as the first tangent turns during an\nanimation and twist the tube by 90 degrees in one frame — so an omitted or\ntangent-parallel `normal` throws. Every segment must meet the next at the same\nposition and tangent. Discontinuities and corners throw a teaching error.\n\nThe closed segment vocabulary is:\n\n- `{kind:'line', start:[x,y,z], end:[x,y,z]}`\n- `{kind:'arc', center:[x,y,z], axis:[x,y,z], start:[x,y,z], sweepDeg}`.\n  Axis is a nonzero normal. Sweep is signed, nonzero, and at most 360 degrees.\n  Start lies in the circle plane and determines its radius.\n- `{kind:'bezier', points:[p0,p1,p2,p3]}`. This is a cubic Bézier with four\n  finite vec3 control points. It uses adaptive five-point quadrature for length\n  and numerical parallel transport; stationary tangents and cusps are errors.\n\nThe runtime projects each original mesh vertex onto the analytic rest\ncenterline and retains its longitudinal coordinate and transverse offsets.\nNormalized arc length maps to the posed path; length changes therefore stretch\nor contract the visible tube. This is a display mapping, **not a tendon tension,\nspool payout, friction, collision, or constant-length solver**. An author must\nsolve those mechanics and pass the resulting centerline. Tube thickness remains\nunchanged. `twistDeg` rotates each cross-section in its transported frame; it can\nshow phase travel in an authored helical braid without moving the centerline.\nIt does not itself simulate material payout.\n\nThe source STEP tessellation is immutable. Each occurrence retains a rest\nmesh and one reusable displayed mesh; unchanged component buffers remain shared.\nStraight STEP surfaces may contain only\nend rings, so the runtime first splits their existing triangles into bands of\nat most `maxSegmentLength` mm in rest arc length. This preserves the exact rest\nsurface, interpolates attributes, preserves CAD face picking, and prevents an\nanimated bend becoming a single end-to-end chord. The default is 1 mm; values\nbelow 0.05 mm are rejected. A per-occurrence 700,000-triangle ceiling throws\ninstead of exhausting memory. Refinement is cached until the rest path or band\nlength changes. Original smooth normals are transformed by the deformation's\nlocal inverse-transpose; silhouettes and technical edges follow the surface.\n\nEvery evaluation starts from rest. Omitting `deformTube` on a later frame or\nclearing animation restores the rest shape. Shared component meshes are never\nmutated. A changed centerline can be independently inspected without a renderer:\n`evaluateAnimationClip` returns `deformations`, keyed by occurrence ID. A\ndeformation is the NUMBERS — `restSpec`, `pathSpec`, `twistDeg`,\n`maxSegmentLength`, `braid` — validated and owned, never the author's arrays and\nnever a compiled path: a morph bake fits on a 96 Hz grid, and a sample that held\nits two arc-length tables would hold hundreds of KB per tube per grid sample.\n`compileDeformation` resolves the two paths through a bounded LRU, for as long\nas the caller is posing with them; `sameTubeDeformation` and `sameTubeRestShape`\ncompare two deformations by value. `compileTubePath`, `sampleTubePath`, and\n`projectTubePath` are exported from `common/tubeDeformation.js`. Line and circle lengths/curvature are analytic;\n`minRadius` for a Bézier is a sampled diagnostic and must not substitute for an\nindependent curvature gate. As with any CAD tessellation, a finite surface mesh\napproximates the analytic posed centerline between its vertices.\n\nFor a smooth CAD core, `braid:{pitch:0.8, depth:0.02, strands:8}` adds a\nprocedural braided surface finish. Pitch and normal-relief depth are millimetres;\nstrands is an even carrier count from 2 to 64. Crossing helices, fine fiber\nlines and lighting relief use rest material coordinates and follow\n`twistDeg` as the tube deforms. This is a GPU normal/color finish, **not added\nCAD fiber bodies or collision geometry**; it remains inside the nominal core's\nrendered envelope. No image texture is fetched and no helical solids are\ncreated. The finish is scoped to that occurrence's material and disabled when\nthe effect is omitted. The original material's source color and shader hooks\nremain in force. Both shader stages — the GPU vertex transport and the braid\nfinish — install through one shared hook (`tubeMaterialShader.js`) that runs\nthem in a fixed order and hands the braid its rest coordinates through one\nvarying, so enabling the braid on a later frame than the deformation renders\nthe same as enabling both at once.\n\n## The runtime is a lazy chunk\n\n`deformTube` is the only producer of a tube deformation anywhere in this\npackage — a step module's effects carry only what an animation frame already\nput there — so a document with no `animation.source` can never reach this code\nand must never download it. `common/tubeDeformationChunk.js` is the boundary:\n`tubeDeformation.js`, `tubeGpuDeformation.js`, `tubeBraidMaterial.js` and\n`tubeMaterialShader.js` load behind one dynamic import, ~29 kB out of the CAD\nViewer's initial bundle.\n\nThe load happens at the single async door every clip must pass through:\n`compileAnimationSource` (and so `loadSourceAnimation`, and `mesh-export.mjs`)\nawaits it before any clip object exists. Declaring an animation is the gate,\nnot calling `deformTube`, because a clip may reach for a tube at any `t` and a\nframe must never fall back to the rest pose. Evaluation itself stays\nsynchronous and always sees a loaded runtime, so an animated tube renders\nexactly as it did when this was a static import.\n\nCallers outside the animation path — the scene's effect resets, the display\nedge-line pass — go through `tubeDeformation()`, which answers `null` until the\nchunk lands. That is exact rather than merely tolerant: every one of those\ncalls is a reset or a replay, and a record can only hold tube state after a\ndeformation was applied. `deformTube` cannot no-op, so it throws through\n`requireTubeDeformation` instead. Code that builds clips by hand rather than\nthrough `compileAnimationSource` — tests, mostly — awaits\n`loadTubeDeformation()` first.\n\n<!-- File: packages/core/docs/workspace-resources.md (Tokens: ~829 | Category: anthropic-skill) -->\n# Workspace CAD services and resources\n\n`CadWorkspaceService` is the renderer's domain boundary. A host constructs one\nservice per workspace and lends it to every mounted CAD view. The HTTP adapter,\n`createCadClient`, implements this contract and retains its catalog, request,\ncache and worker-session lifetimes. Disposing a view releases its render session;\ndisposing the service aborts its resources and sessions.\n\nSurface resolution and editing-preview observation belong to the service.\nThe HTTP implementation owns request encoding, bounded polling, subscriber\ncancellation, replacement views and immutable store URL validation. A pending\nderivation is asked after again at 80 ms, doubling to 640 ms, then at a tenth of\nthe wait so far, at most every 5 s: a derivation stuck behind a daemon that cannot\nstart a worker is not asked many times a second until it fails. A surface\nbinding keeps its tree/view, surface input and concrete object identities;\na mismatched view, input, object or URL fails before publication. UI consumes\nthe resulting tickets and never supplies a same-origin transport fallback.\n\n`service.resources` owns JSON, text, binary and size reads, dependency resolution\nand worker tickets. Pass it as `{ resources }` to render loaders, peeks, sidecar\nloads and robot loads. Relative package assets and URDF/SDF mesh references are\nresolved by the provider. Direct glTF buffers and textures are read through it\nas well; the Three.js loader receives temporary object URLs that are revoked\nonce parsing completes. Up to six nested glTF reads run together, each limited\nto 64 MiB. Workers broker nested references back to the owning provider.\n\nA normal HTTP provider gives workers serializable URL tickets, preserving the\nexisting worker fetch/decode path, including the configured HTTP cache policy.\nConfigured headers stay on the provider origin; external nested references do\nnot inherit them. Custom fetch implementations remain host-owned. A provider with a custom fetch reads bounded\nbytes on its owning thread and transfers an owned ArrayBuffer to the worker.\nThe worker never receives a service function or detaches an admitted cache\nbuffer. SURF byte tickets are acquired only after reserving a worker slot, so\nqueued components cannot accumulate eagerly transferred buffers. Recognition reads are limited to 16 MiB. SURF display-cache hits inspect\nthe cached header before requesting a resource ticket, preserving zero-SURF-read\nwarm rendering. A miss with no SURF URL yet (a part that opened warm, before its\nsurface is resolved) fails as not ready and requests no ticket; the caller resolves\nthe surface and asks again. Aborting a worker request retains the existing scheduler and\nowner rules in [resource ownership](resource-ownership.md).\n\nEach service wraps its provider in an opaque cache generation. URL-addressed\nJSON, text, mesh and descriptor caches include this scope; completed package and\nrecognition caches do too. A fresh server-metadata read that observes a changed\nidentity token retires the generation and aborts its pending reads.\nThe provider exposes that generation signal on the main thread. Worker clients\nand recognition subscribe before acquiring tickets, so already-issued URL\ntickets cannot publish a late result after retirement. The signal itself never\ncrosses into a serializable worker message.\nReplacement services and authentication providers cannot inherit another\nservice's URL cache. Mounts borrowing the same service retain warm reuse.\nContent-addressed decoded tessellations retain their existing exact input/object,\ntolerance and payload-version keys and bounded geometry ownership.\n\nThe public low-level core loaders continue to support omitted providers for\nstandalone static HTTP callers. This is an explicit transport API default,\nusing ordinary fetch and URL-addressed caches; it is not a mutable process-wide\nworkspace provider. Shared UI requires its injected service. `loadSource` is the\nstatic/source composition boundary and creates an HTTP provider when none is\nsupplied; docs and the snapshot entrypoint compose theirs explicitly. Hosts\nrequiring custom credentials, native transfer or workspace isolation must pass\na provider consistently to both loads and peeks.\n\n<!-- File: packages/ui/docs/annotation.md (Tokens: ~3129 | Category: anthropic-skill) -->\n# Annotation\n\nThe drawing tools — Pencil and Shapes — are the CAD viewer's own ink, with no drawing SDK behind it. A view has one\nannotation: its marks are projected into the model in 3D, so they stay where they\nwere drawn from every angle, lying on the surface they were drawn on. The STEP, GLB, mesh, robot and DXF renderers offer them\n([Pencil and Shapes](cad-renderer.md#pencil-and-shapes)).\n\n## Pieces\n\n- `annotation/session.js` — `useAnnotationSession`: the annotation's ink, what the tools\n  put down next (the tool of the strip tool in hand, `pencil` — the pen, the highlighter or the eraser — and `shape` — the line first, the arrow, rectangle, ellipse or text — each kept while the other is up; `style`, the colour, stroke width and\n  placement — `surface`, the default, or `plane` — of the tool in hand, kept per drawing kind in `styles`: `pen`, `highlighter` (yellow\n  `#ffe600`; the others neon red) and `shapes`, which all five shapes share; the eraser has none) and the selection. Each add, erase, restyle of the selection\n  (`restyle`) or fill of a closed shape (`fill`, joining its group) is one step\n  in the file view's history (`renderers/kit/shell/useViewerHistory.js`, 200\n  steps, shared with the renderer's own tools; without one the session keeps its\n  own). Choosing what is drawn next never touches ink already down.\n  `clear()` is a step; `discard()` drops the ink and takes its steps out of the\n  history, for a model that changed under it. The session also keeps the views ink was drawn\n  in (`views`, \"view 1\", \"view 2\"…), as labels for the agent: `noteView(view)` names the one a mark goes\n  down in — a view drawn in before that is the same (`annotation/views.js`'s\n  `sameView`: less than 2° of turn, 5% of zoom or pan, nothing else changed),\n  or a new one.\n- `annotation/views.js` — a view as a prompt carries it, in a snapshot job's own\n  words: the camera (always orthographic), Clip when on, the one motion on\n  screen (Explode, or the pose off its default as `kinematics` or\n  `jointValues`), the parts hidden and isolated, and the picture's size. Never\n  how it looks.\n- `annotation/tools.js` — the two strip tools that draw and their tools: Pencil (`freedraw`, `highlighter`,\n  `eraser`) and Shapes (`line`, `arrow`, `rectangle`, `ellipse`, `text`); `toolFamily(tool)`, `styleSlot(tool)` (the style slot a tool draws with; none for the eraser).\n- `annotation/toolbar.jsx` — `AnnotationToolbar`, the sub-toolbar both strip tools share: one row (at every width; a phone's\n  wraps where it has no room) — the tools of the strip tool that is up, a button each, the one in hand pressed (Pencil's Pen,\n  Highlighter, Eraser; Shapes' Rectangle, Line, Arrow, Ellipse, Text); a rule; Color, Stroke width and Placement (On\n  surface, On plane), dropdowns of the tool in hand's own style (disabled under the Eraser), Stroke width's glyph a straight line drawn at the weight (four, 1–6px, unnamed in its menu; Color's and its menus have no heading), then Clear;\n  Undo and Redo are ⌘Z and ⇧⌘Z, with no buttons. A flat view (a DXF) passes `placements={false}`: its ink has nowhere to lie but the drawing.\n  `annotationColors` offers\n  neon swatches and a neutral that reads against the theme: white in dark mode,\n  near-black in light mode. A native color picker adds a custom color.\n  `AnnotationStylePickers` also restyles the selection's ink.\n- `renderers/kit/tools/annotate/AnnotationSelectionBar.jsx` — the bar over the ink Select\n  picked: its colour and weight, Fill for a closed shape (seeded\n  inside its outline as it falls on the screen, `annotation/geometry.js`'s\n  `interiorPoint` and `fillMarkAt`, and projected where the shape lies: On plane on the\n  shape's own plane if its points are coplanar (a fitted plane), else On surface) and\n  Delete.\n- `annotation/geometry.js` — each tool's mark in screen space:\n  - The highlighter (`highlighterMark`) is the pen's smoothed line at one broad width — five times the stroke width\n    (`HIGHLIGHTER_WIDTH_FACTOR`) — 35% opaque (`HIGHLIGHTER_OPACITY`), round-ended, with no `widths`: an ordinary\n    stroke mark (`width`, `opacity`) that no new field or kind describes. `strokeWidthAt` and `strokeSettingOf` convert\n    between a stroke's width and the setting (1, 2, 4) for restyling. Translucent strokes are drawn once-only\n    in the model (`core/common/annotation.js`: a stencil reference per stroke), so its overlapping segments do not darken.\n  - The pen is smoothed as it is drawn, and each point's width comes from its\n    pressure (a stylus's own, or simulated from speed with a mouse), using\n    `perfect-freehand`.\n  - Rectangles get rounded corners.\n  - A fill finds the area inside a shape with\n    `@text-to-cad/core/lib/annotation/fillRegion.js`.\n- `renderers/kit/tools/annotate/AnnotationOverlay.jsx` — the surface over the viewport\n  while Pencil or Shapes is up. It turns every gesture into marks, previews a mark on its\n  own canvas as it is made, and puts it into the model when the gesture ends.\n  Text is written where the surface is pressed: Enter, or a press elsewhere,\n  puts it down as a text mark; Shift+Enter makes a new line; Escape drops it. The\n  surface stops the press's mousedown, which would otherwise take the keyboard\n  back from the text it just opened.\n  Each mark names the view it was put down in, and is projected onto the surface it starts on.\n  The drawing tools have no Select of their own:\n  the viewer's Select picks ink (`RendererShell.jsx`, through the viewport's\n  `annotationAt`), Shift adding to the pick, and Delete and Escape act on it (here too).\n  The camera stays the person's between strokes: the wheel zooms, a right or\n  middle drag orbits, and while Space is held the pointer is the view's.\n- `mountAnnotations(runtime).update(items, selected, hovered)` haloes the ink Select\n  picked, a wider line in the selection's blue behind it, and, fainter, the ink\n  its pointer rests on (the shell's `hoveredAnnotation`, the marks drawn with it too). Ink draws after the model and its\n  highlights (`ANNOTATION_RENDER_ORDER`), so a lit part never paints over it. Nothing else is drawn: no\n  pane, no label. The ink's box (`runtime.annotationBounds`) widens the camera's depth range.\n- `renderers/kit/tools/annotate/annotation3d.js` — projects a mark into the model.\n  - Every point stays on the ray through where it was drawn, so from that view the ink is\n    exactly what was drawn. **Ink lies on the surface it is drawn on.** Every mark has a\n    **level**: the plane of the surface it starts on (a stroke's first point, a text mark's anchor\n    corner, a fill's first outline point), found by `levelOf(runtime, start, size)`. A flat face's\n    level is its own plane; a curved face's, its tangent plane at the start point (the geometry's\n    smooth normal there). The normal is the surface's, **not the view direction**: from any\n    angle, a mark started on a plate's top has the plate's top plane as its level. No sheet, no\n    pane: nothing is made per view, and nothing is held between marks.\n  - **Placement** says what a point does, per mark at draw time. *On surface* (the default): each\n    point drops onto the visible surface under it (a stroke follows a curved face and steps across\n    faces); a point with nothing under it (past the model's edge, over a through-hole) takes the\n    level: its ray met with the level plane, so a stroke that trails off a face carries on at that\n    face's level. *On plane*: every point is its ray met with the level plane — flat across a\n    curved face, staying on the tangent plane where it started, and carried off the surface at\n    the same level. Both modes agree off the surface. A stroke's line is first split into pieces\n    of a few pixels (`subdivideStroke`) in both, so a two-point line reads the surface all along\n    it; the pen's widths are split with it. In On surface a point takes the nearest depth within\n    its own reach — half the stroke's width and a pixel, never a neighbour's, so a slanted face\n    does not lift the ink off it; a fill point by point. Every point is then two screen pixels toward\n    the eye, text eight. Text is a card facing the view, at the level's depth under its anchor\n    corner, in both modes. Ink already down never moves with the setting.\n  - **The level, when the start is over nothing** (in order):\n    1. the surface under the start point (a ray through it);\n    2. the **visible surface nearest to the start on the screen**: in a depth render of the parts\n       over the window (read back at no more than 256 pixels on the long side), the covered pixel\n       with the least screen distance to the start, found by searching outward in rings of pixels\n       from where the start falls (the nearer of two as far wins), then ray-cast through its middle\n       for the surface point and normal there;\n    3. nothing drawn in the window, but a model: the drawn surface **nearest to the window on the\n       screen** — the same render over a region round the window, large enough to hold the model's box\n       on screen (the window's size on each side where part of the box is behind the eye), at low\n       resolution; the covered pixel with the least screen distance to the window rect;\n    4. no model drawn: the plane through the origin facing the view.\n\n    \"Drawn\" is what the depth render sees: hidden parts and what Clip cuts away do not count.\n    Clip's cap faces are not part meshes (stencil fills, `sectionCaps.js`), so they are not in\n    the render: through a cut the level is the surface seen inside. Without a GPU float render the\n    same reads come from rays, at a far lower resolution. The depth renders are made only when a\n    level needs them, once for all the marks put down together (`projectAnnotations`).\n  - **Grazing guard:** a level whose plane is nearly edge-on to the view (|normal · view\n    direction| below 0.08) is replaced by the plane facing the view through the same point, else rays\n    would stretch the mark along the plane.\n  - `projectAnnotations(runtime, marks, size, { placement, topologyAt, level })` projects marks,\n    finding each one's level inside; `level` ({ point, normal }) gives all of them one instead (a fill\n    of a flat shape lies on the shape's plane).\n  - What the model hides of the ink is drawn faint through it (core's `ANNOTATION_HIDDEN_OPACITY`,\n    on the viewer and in snapshots), so a profile drawn through a part's middle is seen.\n  - Every mark is in model coordinates: it stays where it was drawn whatever\n    Explode, Position or hiding a part does to the model. Clip cuts it.\n    `touches` says what it was drawn over, most first, the same for either placement: the faces and edges the\n    renderer names under it (`ref`, a prompt reference, from the shell's\n    `inkTouches`; the STEP renderer's reads them whatever Select's filter picks),\n    each with its part; without it, the parts.\n  - The projected ink is plain data (`AnnotationItem`): an annotation mark\n    (`@text-to-cad/core/common/annotation.js`), kept across scene rebuilds.\n    `mountAnnotations` draws it in a viewer runtime and `update` draws only the\n    marks a change adds; the objects are core's, the ones a snapshot draws an\n    annotation with. The preview\n    holds a finished mark until the scene has drawn it. `annotationDocumentOf` writes the\n    annotation as an annotation file. The file format is unchanged: marks are 3D points; the level is the viewer's own.\n  - A double-click under Select (Look at, `kit/camera/lookAt.js`) is navigation only; it sets\n    no plane for ink.\n\n## Lifetime and capture\n\nThe live annotation is session-only: it is never written to tab or file state. It\noutlives the drawing tools being put down, because the ink stays in the model and the next\none adds to it. It goes when the person clears it, or when the model it was\ndrawn on changes.\n\n⌘C / Ctrl+C while Pencil or Shapes is up copies the view as it is on screen, ink\nincluded, to the clipboard as a PNG.\n\nQuick Edit captures the current view and distinct views with remaining ink when\nCopy Prompt, Queue or Send is pressed (`host.promptFiles`). The snapshot packet\nincludes shared annotation and measurements: up to 1 KiB of compact UTF-8 JSON\ntravels inline, and larger packets are saved to a temporary `.snapshot.json`.\nNo images are rendered at handoff. Save inline JSON to a file before running\n`cadgen snapshot --job <file>` to render those views over the model or an edit of it.\nThe Annotation chip does not copy references or save individual files.\nQueue and Send clear the annotation with the note; Copy Prompt keeps it.\n\n<!-- File: packages/ui/docs/cad-renderer.md (Tokens: ~12142 | Category: anthropic-skill) -->\n# CAD renderer\n\nThe CAD viewer is the viewport, the floating tool strip and the tool in hand's\nsub-toolbar under it, the file sidebar, reference interactions, measurement and annotation\ntools, animation, loading artwork and alerts, in `@text-to-cad/ui`. The rules of its\nchrome — which tools a file has, how they take and give back the pointer, the file sidebar,\nPreview, the keyboard — are the\n[design system](settings-ui.md); this document is the mechanism behind them.\n\nThere is one renderer per file family over a shared, format-blind [kit](#kit) and\n[shell](#shell). The STEP renderer is under `src/renderers/step`, on the kit like\nevery other family: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`, which\nruns `useRendererShell` exactly as the other renderers do and composes STEP's own\nmodules around it (see the end of [Shell](#shell)). Applications use the registrations and the shared\n`FileViewer`; they do not import another application's source.\n\n## Kit\n\n`src/renderers/kit` is the format-blind half of the viewer: small modules a\nrenderer composes, none of which asks what it is showing. There is one renderer\nper file family, each a vertical slice over the kit: `src/renderers/dxf`\n([DXF renderer](#dxf-renderer)), `src/renderers/glb` ([GLB renderer](#glb-renderer)),\n`src/renderers/mesh` (STL and 3MF, [Mesh renderer](#mesh-renderer)),\n`src/renderers/robot` (URDF, SRDF and SDF, [Robot renderer](#robot-renderer)) and\n`src/renderers/step` (STEP and STP, [STEP renderer](#step-and-source-separation)). The kit imports itself, shared UI\n(`primitives`, `lib`, `drawing`) and the format-blind half of `@text-to-cad/core`\n(`lib/viewer/*`, `lib/perspective.js`, `common/viewSettings.js`,\n`common/sceneSettings.js`, the Render studio); a renderer imports the kit, never\nthe reverse.\n\n| folder | what it is |\n| --- | --- |\n| `viewport/` | `useViewerRuntime` (three.js renderer lifecycle, on-demand render loop and `requestRender`, resize and device-pixel-ratio caps, context loss, keyboard orbit, `setSuspended`, teardown), `gpuDevice` (the page's one WebGL context, shared by every viewport: see [the GPU device](#the-gpu-device)), `previewViewScene` (Preview's view of the file's Render settings: no clip or explode, one scene-quality tier up, `previewSceneQuality`), `framePresentation`, `viewportBuffer`, `renderDepthPolicy`, `sceneObjects` (`disposeSceneObject`), `thumbnail`, DOM helpers. The scene in the viewport is its owner's: teardown calls the injected `disposeScene(runtime)` and `disposeStudio(runtime)` and disposes its lights' shadow maps; a runtime with a renderer of its own (no device) then loses its WebGL context (`forceContextLoss`), since three's shared textures keep every renderer that drew them reachable and only a lost context gives its GPU memory back, and one on the device lets go of its view and never ends the device's context. A torn-down runtime draws no further frame. The orbit controls let go of the page in a layout cleanup, before React detaches the canvas: they listen on its root node, which is no longer the document once it is detached. |\n| `camera/` | `runtimeCamera` (zoom percent against the authored framing, projection and lens sync, perspective snapshots, eased transitions, fit-to-bounds, recentre), `useViewportCamera` (that behaviour bound to a mounted viewport: the perspective kept by a mounted view, the reset that Zoom to fit and the live `resetCamera` share, view-cube presets, which turn the camera and keep its zoom and target), `viewportCameraKit` and `viewportCameraFit`, `orbitControls`, `zoomPivotReanchor` (a wheel step first moves the pivot's depth onto the surface under the cursor, and a perspective pan is scaled by that surface's depth, so Render pans and zooms what the cursor is on at Solid's rate), `zoomSpeeds`, `cameraLens`, `ViewPlaneControl` (view cube). |\n| `look/` | `stageEffects` (lighting rig scaled to the model, floor, glow and shadow catcher, grid and origin axes), the Render studio boundary (`renderStudioChunk`, `studioEnvironmentCache` and its worker). `chromeBackdrop` and `useChromeBackdropColor` (the frame colour around a scene). The surface LOOK is data the viewport resolves and a scene applies to its own materials: `@text-to-cad/core/lib/viewer/surfaceLook.js` (`createSurfaceLook(THREE, root).apply(look)`) does it for any authored material tree. The viewport resolves it with core's `resolveSceneSurfaceLook` (`common/sceneSettings.js`), the resolver the snapshot CLI dresses the same scenes with. |\n| `view-settings/` | The settings model and store (`viewSettingsStore`, `useViewSettings`, `viewerDisplaySettings`, `renderState`), applying a change to a viewport (`useAppliedViewSettings`, `viewUpdateCoordinator`, `viewUpdateGate`, `viewUpdatePlan`), the content of Display's dropdown (`DisplayModeOptions`: the modes and the Grid switch's icons; `DisplayPopover` draws it), and Preview's settings form (`PreviewRenderSettings`). |\n| `tools/` | `FloatingToolBar` (the dumb strip: the tools it is handed, in groups parted by one thin rule, then, only where the host has no navbar, the view's controls, which end the last section the tools have with no rule of their own; after a rule only where the tools are a single group), `SubToolbar` (the tool in hand's one row under the strip, its groups parted by `SubToolbarSeparator`; `SLIDER_SUB_TOOLBAR_CLASS`, the one width of a slider tool's row), `toolModes` (the tool-mode state machine), `ToolModeMenu` (`ToolSettingsMenu`: a tool's settings dropdown, from a chevron right after its button on the strip, or a sliders button in a panel's heading), `ToolPopover` (an ordinary dropdown from its button, on a `side` and `align`ed start or end: the Animation section's Playback settings), `FileSidebar` (the file's sections, whatever the tool: a tile each at the top-left, a tile opening its section in a panel under them, one at a time, sized from its bottom-right grip; on a phone one tile, `phone`, opening one panel with the sections as tabs and its one X; Preview offers its Position and Animation alone, `sections`, its tiles `bare`), `ToolStack` (the bounded column under the strip and sub-toolbar for a renderer's own `toolPanels`, which scrolls only when what cannot give way still does not fit), `ToolPanel` (one panel of the stack, or one section of the sidebar: `fit` says how it gives way when the viewer is short, `resizable` makes a stack panel the person's to size by the grip in its bottom-right corner alone, moving only it, and a panel with a heading can fold to it; its `footer`, a `ToolPanelFooterButton`, is a full-row action under the body that never scrolls), `ResizeGrip` (the one resize grip of a box over the viewport, in a bottom corner: the sidebar's and a resizable panel's at the bottom-right, Quick Edit's at its bottom-left), `toolStackLayout` (every panel's width, each sidebar section's and the resizable panels' sizes, and the folded stack panels: defaults, bounds and their stored record), and the format-blind tools: `annotate/` (`AnnotationOverlay`, the surface the drawing tools' gestures land on; `annotation3d`, its ink projected into the model; `AnnotationSelectionBar`, the bar of the annotations Select picked), `PreviewChrome` (Preview's chrome: the tools view's controls hidden and inert while it is up, and its own corner, `end`, with its settings panel under it, `panel`), `PlaybackMenu` (the Playback settings — Speed, Loop, Autoplay — and `ORBIT_SPEEDS`, Preview's Orbit speeds), `playbar/` (`AnimationSection`, the file sidebar's Animation section: Reset in its heading, the routine where there are several, Play/Pause, the scrubber, Playback settings; `AnimationTimeControl`, the scrubber, and `animationControlsHaveContent`; `animationClock`, `usePlaybackFrames`, `playbackPreferences`: the file's playback settings — Orbit's speed, Autoplay, and the chosen Speed and Loop), `pose/` (the handle overlay, canvas, drag mathematics), `select/` (`SelectFilterMenu`, Select's selection filter on its chevron; `selectFilter`, its pure toggles; `usePointerPick`: taps and hover through a scene's own `pick`, each with where the pointer was; `useSceneSelection` and `sceneReferences`: the parts a scene's pick names, selected, lit and handed to Quick Edit as references), `measure/` (Measure, for any 3D view: `useMeasureSession`, the session; `useMeasureOverlay`, its drawing; `MeasurementCard`; `measurementChips`, its chips and its row under the strip (`MeasurementsBar`); `measureSelection`, Select's press and hover on a measurement; `SceneMeasure`, two points on a scene's surface, and the model's size row), `model-effects/` (Explode and Clip, for any scene that opts in: `ModelTools`, their strip tools and slider rows; `useSceneEffects`, the viewport applying them to the scene; `motionExclusion`, one motion at a time), `sceneTools` (the tools a scene's renderer composes around the shell: the tool in hand, Pencil and Shapes, Measure and the model effects), `quick-edit/` (`QuickEdit`, the note to the agent, and `quickEditPrompt`, the context it builds and the text it copies). Screenshot capture is `@text-to-cad/core/lib/viewer/screenshotCapture.js`. |\n| `inspector/` | `FileSheet` and its row and section primitives, `modelTreeSearch` (`useTreeSearch`, the ranked flat search every tree shares), `VirtualRows` (a long tree's rows, windowed), `referenceRows` (`InfoRow`, `MonoValue`, `CoordValue`), `kinematicsControls` (the `Pose` row that heads every Position section: Default, the named poses, Custom). The tree row and filter box are `primitives/tree-row` and `primitives/tree-filter`. |\n| `status/` | `LoadingIndicator` and `ViewerLoadingOverlay`, `ViewerAlertCard` (the card over the viewport for every alert, and `useAlertDismissal`: a card put away, and its icon in the navbar that brings it back) and `reportIssue` (`alertIssueUrl`: its Report Issue's new issue), `ViewUpdateStatus`, `loadingState` (`viewerLoadingState`), `loadAlerts` (`failureAlert`, `noGeometryAlert`). |\n| `shell/` | The host glue every renderer needs that is not about its scene: see [Shell](#shell). |\n\n**What a view opts into.** Display's settings and `resolveViewSettings` take explicit\nlists, `features: { modes, settings }` (`ViewFeatures` in\n`@text-to-cad/core/common/viewSettings.js`): the modes it lists and the settings\nits menus offer (`VIEW_SETTING_IDS`). A setting left out does not affect the\nscene, and saved settings are never rewritten. `viewFeaturesForKind(kind, { parts })`\nis the one rule of which list a file gets: `ALL_VIEW_FEATURES` for a STEP;\n`PARTED_MESH_VIEW_FEATURES` (Solid, Clip and Explode) for a GLB of several rigid nodes\nthat draw; `MESH_VIEW_FEATURES` (Solid and Clip) for any other 3D file; and\n`EDGELESS_VIEW_FEATURES` (no Edges, Clip or Explode) for a drawing. Render is no mode. Every\nrenderer hands its list to the shell, which configures the view-settings store\n(`configure({ features })`) and Display's settings from it, and to its Explode and Clip\ntools. The headless renderer (`renderMeshScene.js`) resolves a job of each family under the\nsame rule.\n\n**Tools.** The strip draws the list it is handed: `{ id, label, icon, active,\ndisabled, onSelect, description?, menu? }` — a press is a tool's only action; a tool with\nsettings has them as `menu`, a chevron right after its button (Select's filter, the only\none), and what it can be set to while it is up is its sub-toolbar. Display is not a tool:\nits settings are a dropdown from its button at the navbar's right end, before Preview\n(`kit/shell/DisplayPopover.jsx`), a 3D view's alone. A renderer\nbuilds its own list from `shell.tools.own(...)`, adding `shell.tools.drawing` (Pencil and Shapes) where it offers\nthem, declared in its `toolModes` as two modes (`pencil`, `shapes`) (a press on the tool in hand does nothing); STEP's is in `step/StepSurface.jsx`.\nRoutines play from the file sidebar's Animation section, which the shell adds for any file with\nroutines (`kit/tools/playbar/AnimationSection.jsx`), in the tools view and Preview alike. Preview is not a tool: its button, after Display at the navbar's right end, enters it. Nor is Quick Edit: it is a box at the top-right, there while something is picked or annotated. `createToolModes({ defaultMode, modes })`\nanswers what a press does (`next`), what a saved tab may record (`persisted`) and\nwhich tool a file opens in (`restore`); the STEP declaration is\n`CAD_TOOL_MODES` in `workbench/constants.js`. The scrubber follows the clock on\nthe runtime it is handed (`runtime.clock`, an `AnimationClock`); Position's knobs\noverlay (`tools/pose/`), Select's while its filter ticks Position, takes a plain handle list, as a prop or (for an\nowner that poses its model outside React) as a ref whose list it replaces per pose.\n\n**The scene contract** (core's `lib/viewer/sceneContract.js`, a JSDoc typedef that `kit/scene.js`\nre-exports, because the snapshot CLI holds the same scenes): `{ object3D, bounds,\nrestBounds?, dispose(), setSurfaceLook?(look), setShadowReception?(receives),\nkeepsAuthoredFinish?, complete?, pick?(ray), placedObjects?() }`, and for a family's scene\nthe tools' half: `units` (the file's unit against the scene's coordinates, core's\n`sceneUnits.js`), `setClipPlanes(planes)`, `explodeParts` and `setExplode(amount)` (a GLB),\nand, for a scene whose parts are picked by an id of the file's own, `hasPart(id)`,\n`describePart(id)` and `setHighlight({ hovered, selected })`.\nThe viewport adopts `object3D`, frames `restBounds`, lights and floors `bounds`,\nand hands over the surface look the Display settings resolved\n(`{ materialSettings, authored, surface: { style, opacity } }`); it only ever\ndetaches a scene, whose owner disposes it. `buildModel`\n(`@text-to-cad/core/common/cadScene.js`) exposes `object3D`, `bounds`, `restBounds`\nand `dispose()`; the GLB and mesh renderers' scenes implement all of it but `pick`,\nand the robot renderer's all of it. A family's `pick(ray)` answers the first surface Clip has\nnot cut away, with the point it hit (`point`, for Measure). `pick(ray)` is what `usePointerPick` calls: the\nhook owns the pointer (which press is a tap, one hover pick per frame, the\ncursor), the scene says what is under the ray, and its renderer says what a hit\nmeans. A tap acts at once: nothing waits to tell it from a double-click.\n`placedObjects()` is for the near/far fit alone: a camera inside a mostly empty\naggregate box can still be well outside everything visible in it, so a scene that\nplaced many things says so and is fitted on them instead of on its whole box.\nShadow reception is the viewport's for every mesh of a scene, unless the scene\nimplements `setShadowReception`: a scene with unlit or see-through surfaces is told\nthe setting and applies its own rule (a STEP's watch crystal never takes a shadow).\nA mesh names where \"Color by part\" deals it a palette colour in\n`userData.cadFillIndex` (otherwise meshes take the palette in traversal order),\nand a material with no source colour says so with `userData.cadSourceColor = false`.\n\n**The rule and its check.** Kit code names no file format, no file-kind switch\nand no STEP-assembly concept (topology, selectors, display records, explode,\nsection clipping), in code or in comments; only `view-settings/` may name the\nClip and Explode sections a view opts into, and `tools/model-effects/` the two effects\nit applies to whatever scene opts in. `npm run check:boundaries` runs\n`scripts/test/check-kit-boundaries.mjs`, which scans every kit source for imports\nof a renderer or of a file-family core module and for those words, against a\nshort allowlist that carries a reason per line and fails when an entry goes\nstale. The same script holds every split-out renderer to its slice\n(`RENDERER_SLICES`): a renderer imports the kit, `renderers/workspace` (the\nbackend connection a file is prepared against, the host's viewer preferences and\ncommand types, and `useWorkspaceDocument`), shared UI and core, and never another\nrenderer. It holds the host's file viewer (`src/file-viewer`) to the mirror of\nthat rule: the file viewer mounts a renderer through the registry and imports\nnone itself, so no host pays for a family it is not showing. The\nunbound-identifier test (`src/renderers/unboundIdentifiers.test.js`) scans the\nwhole renderer tree, so a renamed or added slice is covered the moment it\nexists. The same script's `checkSharedSceneBuilders` holds the rule of the next\nsection: each family's builder, loader, opening pose and look are imported by the\nrenderer AND by the snapshot's `common/headlessScene.js` from one core module, are\ndefined nowhere else, and no module on the snapshot's render path imports a mesh\nflattener.\n\n### One scene builder per family: the viewer and the snapshot CLI\n\n`cadgen snapshot` of a GLB, an STL, a 3MF or a robot description draws the scene the\nviewer draws, because both call the same builder. A family's scene construction lives in\n`@text-to-cad/core` (no React, no DOM beyond three.js); its renderer here keeps the loading\nstate, the tools and the panels, and the snapshot page (`common/headlessScene.js`, routed\nfrom `common/headlessRenderEntry.js`) keeps only what a still has and a viewport does\nnot: the camera fit, the studio set up per output, and PNG encoding.\n\n| family | builder (core) | viewer caller | snapshot caller |\n| --- | --- | --- | --- |\n| GLB | `lib/render/glbScene.js` `createGlbScene`, over `loadRenderGlbDocument` | `glb/useGlbScene.js` | `GLB_FAMILY` |\n| STL, 3MF | `lib/render/meshScene.js` `buildMeshScene`, over `loadRenderMeshByUrl` | `mesh/useMeshScene.js` | `MESH_FAMILY` |\n| URDF, SRDF, SDF | `lib/urdf/robotScene.js` `createRobotScene` over `robotParts.js`, loaded by `lib/urdf/loadRobot.js` | `robot/RobotRenderer.jsx`, `robot/useRobotDocument.js` | `ROBOT_FAMILY` |\n| STEP | `common/cadScene.js` `buildModel` | `step/scene/stepScene.js` | `headlessRenderEntry.js` |\n\nBoth hosts dress a scene the same way: the look from `resolveSceneSurfaceLook`, shadows\nwhile the lighting is on, `keepsAuthoredFinish` for Inspect's reflection environment, and\nthe ground sized from `restBounds`. Both cut and explode it the same way: Clip through\ncore's `lib/viewer/sceneClip.js` (the plane in the box the camera frames, `at` in the\nfile's unit, the cut capped as a STEP's is), Explode through the scene's `setExplode`\n(`lib/viewer/sceneExplode.js`, a STEP's layout over the file's tree). A robot's parts a\nQuick Edit's view hid come back in its job's `selection.hide`. A robot opens at `robotOpeningPose`\n(`lib/urdf/motion.js`: every joint's default, then an SRDF's `home` state) and a\nsnapshot's `--joint-values` go on top through the same joint matrices. What a snapshot\ncannot express, it refuses rather than approximates: a GLB's clips play in the viewer's\nAnimation section and have no snapshot flag. The pieces are pinned by `common/headlessScene.test.js`\n(core), `kit/view-settings/renderState.test.js` (one look from either route) and\n`tests/python/packages/cadgen/test_snapshot_family_scenes.py` (real snapshots, read as\npixels).\n\n### Shell\n\n`kit/shell` is what a file-family renderer needs from its host that is not about\nits scene. A renderer loads its document, builds its scene (`kit/scene.js`) and\ncalls one hook; the shell owns the rest.\n\n| module | what it is |\n| --- | --- |\n| `useRendererShell.js` | The hook. Per-file state through the host, the Display settings store and the content of Display's dropdown (`shell.display`), tool modes (Pencil and Shapes are the strip tools the shell itself owns, `shell.tools.drawing`; Display, Preview and its controls are the frame's; `toolModes` is omitted altogether by a renderer with no tools of its own), the file sidebar's and tool stack's layout (a viewer preference: `services.preferences.toolStack` — each sidebar section's size, the resizable panels' sizes, the folded stack panels — changed by one patch per gesture, `frame.changeToolStack`), prompt snapshots (the host's `captureRequest`), the clipboard screenshot (the drawing tools' Copy), what Quick Edit takes from the view (its file, how a copied prompt spells a path, the view as a picture, the viewer's Escape), the one Preview state (`shell.previewing` / `setPreviewing`, from `usePreviewState`; every way in and out goes through `setPreviewing`, which mounts Preview's view or takes it away and touches nothing of the editor's), Preview's scene (`frame.previewDesiredScene`: `previewViewScene(store.renderScene())`, Render over the file's settings as Preview draws them) and its viewport (`frame.previewViewRef`), Preview's Orbit (`shell.orbiting`, transient, off at every entry and exit), Render's studio code and environment prepared 600 ms after the model is on screen at Preview's quality, into the GPU device's cache Preview draws from (`previewPrepare`; it changes nothing the editor draws; not on a software renderer, the viewport's `softwareRendering`, where Preview prepares as it is entered), and Autoplay (`shell.autoplay`), alerts, shortcuts, and the live command surface (`setCamera`, `capture` and `resetCamera` act on the view on screen: Preview's while it is open). It owns no zoom control: the shell has none. |\n| `RendererShell.jsx` | The frame: viewport box; one centred column at the top centre (`TOOLBAR_POSITION`, `data-cad-tool-groups`) holding the tool strip (the renderer's tools; the view's controls — Display, the active mode's sphere, whose dropdown is `DisplayPopover`, the modes and the Grid switch, then Preview, a play triangle, for a `previewable` view — are the navbar's, portalled into `view.navbarSlot`, and end the strip only where there is no navbar), under it the tool in hand's sub-toolbar (the drawing tools' `AnnotationToolbar` while Pencil or Shapes is up, else the renderer's `subToolbar`: Explode's, Clip's), the update status, and the tool stack (`ToolStack`: the renderer's `toolPanels`; the column stops above the cube's line, `VIEWPORT_STACK_BOTTOM`); the file sidebar at the top-left (`FileSidebar` over the renderer's `sidebar`, `SIDEBAR_POSITION`: its tiles 8px in from the top and left, the open section's panel under them down to 8px off the bottom; on a phone one tile at the left of the strip's line, the strip padded clear of it, `TOOLBAR_POSITION_PHONE_SIDEBAR`, and one tabbed panel down to Quick Edit's peek, `SIDEBAR_POSITION_PHONE`); Quick Edit at the top-right; the view cube in the bottom-right corner; Preview's controls (Preview settings and Exit preview in the navbar slot, or, with no navbar, at the view's top-right, `PreviewChrome`'s `end`; the settings, a toggle whose panel docks at the view's top-right, `panel` (`PreviewSettings.jsx`: Orbit and its speed, then Preview's own settings, `frame.previewSettings`), then Exit preview, an X — and the file sidebar's Position and Animation alone, bare, at the top-left, `PREVIEW_SECTIONS`; nothing under the model); Preview's view (`PreviewView.jsx`, `data-preview-view`), mounted over the editor's viewport box while `shell.previewing` and unmounted as it closes, the editor's viewport `suspended` under it; the cover that answers the press (\"Preparing preview…\", `data-preview-cover`) until Preview's view has presented its first frame (`onPresented`), no finer detail is on its way (quality neither preview nor refining) and its picture has converged (`pictureProgress`) — lifting at once on a view the shell set moving (Orbit, a routine playing), never on a picture that detail started again (`previewCover.js`); at five seconds it stops waiting for detail and lifts on the picture converged, a second later at the latest, with no cover on the way out; the Animation section after the renderer's sidebar sections, for a file with routines, and the file's chosen Speed and Loop applied to its routine; the loading overlay; and the alert card. While the model loads, and once a load has failed in a way the model did not survive (an alert that cannot be put away), it draws none of its controls: only the load, or the card saying why, with the update status at the top centre in the strip's row; in a compact host (`appearance.compact`), none at all: the model alone. Nothing sits at the bottom centre. It takes `studio`, the renderer's own Preview while it is open: `{ scene, overlay(viewport, drawn), animation, runtimeLifecycle, onCameraSettled }` — the scene Preview draws, built by the family's own builder over the editor's geometry, what drives it from inside Preview's viewport (handed the Render scene it draws), Preview's routine (the Animation section's in Preview; Autoplay starts it on entry), and Preview viewport's lifecycle and settle. It takes `references` (what is selected, in the prompt grammar: what Quick Edit attaches and counts), `referenceChips` (how Quick Edit shows them) and `copySelection` (what ⌘C / Ctrl+C copies while the renderer's own tool is up and something is selected; null when nothing is), and, under Select, `onSelectPress`, `onSelectHover` and `selectsAnnotations` (whether Select's filter picks the annotations), and `inkTouches` (what the model is under a point of the view, `(clientX, clientY) => [{ part, ref? }]`: what the drawing tools record a mark as touching). One DOM structure (`data-slot=\"cad-file-view\"`, `data-cad-surface`, `data-cad-scene-backdrop`, `data-cad-toolbar`, `data-view-controls`, `data-cad-sub-toolbar`, `data-cad-file-sidebar`, `data-cad-tool-stack`, `data-tool-panel`) for every renderer. `frameProvider` wraps the WHOLE frame in the renderer's own context — the sidebar and the tool stack as well as the viewport, because they read it — and `onCanvasPointerDown` is a press that landed on the model, for a renderer with something to put down when the person reaches for it. The frame focuses itself on such a press either way. |\n| `ShellViewport.jsx` | The three.js viewport around ONE kit scene: `useViewerRuntime`, `useViewportCamera`, the look (rig or studio, environment, background, floor, grid, axes), the drawing tools' surface and the annotations' ink in the model, the view cube (bottom-right, `VIEWPORT_CUBE_BOTTOM`; not mounted in Preview's viewport, while the model loads, or when the shell says so, `viewCube`), frame presentation and the queued view-settings handshake. Its children may be a function of the viewport (`{ runtimeRef, hostRef, mountRef, viewerReadyTick, commitScene, syncSceneBounds }`), which is how a renderer mounts its own overlay or pointer pick. A scene that changes IN PLACE (it arrives in pieces, swaps its detail, is rebuilt under one identity) calls `commitScene()` from its own effect: the viewport re-reads what it placed, fits the stage and the depth range and applies the framing rules THEN, because a child's effects run before the viewport's own adoption effect. The one thing a commit never does ahead of the viewport is FRAME under a camera that is about to change: when the same render also changed the lens, the projection or the viewing mode, the stage is adopted at once and the framing follows once the camera has been given those props (a stored camera applied under the old projection and then converted comes out about a sixth smaller). A scene is framed once, on its `restBounds`; a STEP package's is the box its `assembly.json` declares (`bbox`), final from the first publish. A scene that says `complete: false` (a package that declares no box) is framed on what has arrived and once more when it is whole, unless the camera on screen is by then the person's: one they dragged, wheeled, turned by keys or the cube, zoomed to a selection or set through `setCamera` during the current mount, or one a reload restored from the file's view. A rebuild of the open file never re-frames; only Zoom to fit does. A file opened again after the tab left it starts with fresh framing, since leaving it dropped its view. `studio` makes it Preview's viewport (opened at rest, framed afresh from the default direction, keeping no camera, no cube and no drawing tools; `orbit` turns the camera at `orbitSpeed`); `suspended` makes it draw nothing while another view covers it, and draws a frame asked of it meanwhile synchronously, before paint, as it is uncovered (`runtime.setSuspended`). `preserveInteractionPixelRatio` keeps the idle pixel ratio while the camera moves (a scene drawn with hairlines, and Preview's orbit), and `runtimeLifecycle` (`onRelease(runtime, { handoff })` while the WebGL renderer is still alive, `onContextLost()`, `onInitializationError(error)`) is for a renderer that hangs its own objects or in-flight work on the runtime. `syncSceneBounds()` re-fits lighting, shadows and the floor's height to a scene that moved its own bounds, with no React render and no reframe; a sync that finds the bounds (and every setting the fit reads) as they were refits nothing and keeps the shadow maps (`look/stageFollow.js`). What is SIZED stays sized from the rest placement, in Inspect and in Render alike: the grid and stage (`sceneRadiusForBounds` on `restBounds`) and the Render studio's floor plane (`applyPhotographicStudio`'s `groundBounds`), so a pose or a playing routine never rescales or slides the ground under the model; `zoomToBounds(bounds)` frames part of the scene. Read-only test seams, the view on screen's (Preview's while it is open, the editor's again as it closes): `window.__cadCamera()` (the live camera, its depth range included) and `window.__cadStage()` (the ground's radius, the bounds the stage is fitted to, the floor's height, the studio floor's size and centre). |\n| `PreviewView.jsx` | Preview's view: a `ShellViewport` of its own (`preview`, `orbit`, `orbitSpeed`), with its own view-update coordinator over `frame.previewDesiredScene`, drawing the renderer's `studio.scene` and mounting `studio.overlay(viewport, drawn)` inside it. Its canvas, scene, camera and look are its own; only the GPU device is shared with the editor's, which it never reaches. `onPresented` says when its first frame is on screen. |\n| `usePreviewScene.js` | Preview's scene for a family whose core scene has `createSibling()` (GLB, mesh, robot): the editor's scene's sibling, made as Preview opens and disposed as it closes, before the scene it shares geometry with. |\n| `fileView.js` | The file's view `{ version, camera, display, playback, renderer }`, read forgivingly and written exactly: the camera (restored in place of the open-time fit; null fits), the Display settings, the playback settings (`tools/playbar/playbackPreferences.js`), and the renderer's own slices, each `{ signature, value }` and restored only under the signature the renderer declares now (`readFileView(raw, signatures)`); the camera, the display and the playback are always kept. The host keys it `[file path, renderer id]` in the tab store, which keeps it for the file on screen alone. Not in it: the tool in hand, a selection, measurements, ink, Preview, a routine's time — every open starts those afresh. |\n| `liveBinding.ts` | `attachLiveBinding`: the live command surface. Base commands (`readState`, `setCamera`, `resetCamera`, `setDisplaySettings`, `capture`, `thumbnail`) mean the same for every renderer; a renderer ADDS commands by name and DECLINES the known host commands (`HOST_LIVE_COMMANDS`) that make no sense for it with the sentence the caller reads. Binding fails when a renderer does neither. |\n| `promptContext.js` | `createViewPromptContext` (a snapshot and what it depicts) and `promptDeliveryError`. A renderer with a reference vocabulary of its own passes `promptContext` instead, and may then return that vocabulary from `promptReferences`. |\n| `loadReport.js` | `shellLoadReport`: what the shell asks the status kit about one document load, as one pure function, and all it returns is the loading presentation over the viewport. A renderer whose document is a plain download passes `load` and nothing more. One whose document can be EDITED or PREVIEWED while it is open knows more than the shell can: `load.editPending` (queued work of the person's own, with nothing of it on screen), `load.currentPreview` (that work IS what is drawn, so the wait is over even though the write is not) and `load.finding` (the file is not even located yet). An alert is `load.alert`, which the frame draws as the alert card (`ViewerAlertCard`: every alert; one the model survives, a warning beside it included, can be dismissed). |\n| `useViewerShortcuts.js` | Which mounted viewer an Escape or a ⌘C / Ctrl+C belongs to (focus inside it, or the page after a press inside it); the renderer says what Escape means, and a copy is the active tool's (the drawing tools' view with its annotations, else the selection's references, `copySelection`). The order is [the design system's](settings-ui.md#keyboard). |\n| `viewportLayout.js` | The chrome's geometry: its inset from the viewer's edges (`VIEWPORT_INSET_PX`, 8px), the strip's row (`VIEWPORT_TOP_BAR_PX`, 34px), the cube's size and place (bottom-right, 2px from the right, `VIEWPORT_CORNER_INSET_PX`, and `VIEWPORT_CUBE_BOTTOM`, 8px off the bottom so its axes stay inside the view, plus `--cad-viewport-corner-bottom`, which a host whose control floats over that corner sets to lift it clear), the tool column's bottom (`VIEWPORT_STACK_BOTTOM`: an inset above the cube's line). |\n| `ViewportContextMenu.jsx` | The viewport's menu on a secondary TAP (a secondary drag pans; primary and secondary together is the pan chord). The gesture, the anchor, the clamping and the dismissal are the shell's; the ITEMS are the renderer's (`contextMenuItems(press)`), asked at the moment of the press, and `onContextMenuOpenChange(open)` says while the menu is up. A renderer that passes no items has no viewport menu at all — every renderer but STEP. |\n\n```jsx\nconst shell = useRendererShell({\n  view,                 // RendererViewProps, unchanged\n  services,             // { preferences, onPreferenceChange, live?, captureRequest?, acknowledgeCommand? }\n  resource, modelKey, revisionKey,\n  features,             // ViewFeatures: the Display sections this family opts into\n  toolModes,            // createToolModes({ defaultMode, modes }), or omitted: no tools of its own\n  previewable,          // true for a 3D view: it offers Preview; omitted (a 2D view): no Preview, and a request keeps the normal view\n  scene,                // KitScene | null\n  load,                 // { busy, updating?, progress?, alert? }: the renderer's document load\n  animation,            // routine runtime with its own `clock` (and `onRelease`), or null\n  live,                 // { commands?, declined?, state? }\n  // optional: promptReferences, promptContext, escape, sceneScaleMode, preview, tool,\n  //   viewSettings, viewerRef, onCameraSettled, onRuntimeAlert, presentationReport,\n  //   preserveInteractionPixelRatio, runtimeLifecycle,\n  //   rendererState   { signatures, read }: the renderer's slices of the file's view, read when it is written,\n  //                   each restored (by the renderer, `readFileView(view.state, signatures)`) under the same signature\n});\n// Renderer-facing, beside `tools`, `display`, `toolMode`, `selectTool`, `previewing`, `requestRender`:\nshell.requestFrame();            // a frame that keeps the shadow maps: a highlight moves and reshapes no caster\nshell.syncSceneBounds();         // the scene moved its bounds (a pose): the stage follows, no render\nshell.scheduleStateSave();       // a slice changed: write the view soon, and on unmount\nshell.playback; shell.setPlayback(patch); // the file's: Orbit's speed, Autoplay, the chosen Speed and Loop\nshell.orbiting; shell.setOrbiting(on);   // Preview's Orbit, transient: off at every entry and exit\n// The shell adds Display and Preview (a `previewable` view's, in the navbar), Preview's controls (Preview\n// settings, Exit preview), the Animation section for a file with routines, and the sub-toolbar of its own tool (the drawing tools').\nconst tools = [shell.tools.own({ id, label, icon, menu }), shell.tools.annotate].filter(Boolean); // []: no tools, and no strip\n// subToolbar: the renderer's tool in hand's row; sidebar: the file's sections, each a ToolPanel, whatever the tool\nreturn <RendererShell shell={shell} tools={tools} subToolbar={toolRow} sidebar={<>{treeSection}{positionSection}</>}\n  viewportOverlay={viewport => <PointerPick viewport={viewport} scene={scene} enabled={selecting} onPick={pick} onHover={hover} />} />;\n```\n\nA CAD renderer's controls are the shell's chrome: its `tools` on the strip, the tool in\nhand's `subToolbar` (`kit/tools/SubToolbar.jsx`) under it, and its `sidebar`, the file's\nsections down the left edge (`kit/tools/FileSidebar.jsx`), each a `ToolPanel` drawn as a\nsection, whatever the tool: a STEP assembly's Model tree and Position, a robot's Links, an\nSDF's metadata and Position. Sections are their content's height and the bar scrolls as one,\nup to all the room down the left edge or the cap a person set from its bottom-right grip\n(`kit/tools/ResizeGrip.jsx`, Quick Edit's), kept under `sidebar` in the tool stack's layout;\na section folds where there are several, kept by `id`. A tree has no X and is on screen under\nevery tool (out of sight only in Preview, kept mounted with its state); a pick scrolls its row\ninto view there. A renderer may also hand `toolPanels`, the tool stack under the strip and\nsub-toolbar, each panel its content's height, with `fit` saying how it gives way on a short\nviewer: `\"tree\"` first (to 128px or its content), then `\"details\"` together, each in\nproportion to its height (to 96px or its content), never `\"fixed\"`; the column scrolls only\nif the rest still does not fit. Every panel is `TOOL_PANEL_WIDTH` wide (164px, a six-tool\nstrip's); `resizable` makes a panel the person's to size on its own, from the grip in its\nbottom-right corner alone, kept under its `id`. STEP and the robot renderer hand none.\n\nA renderer whose model moves outside React (a robot's pose) keeps that state in\nits own store: `rendererState.read()` is called at the moment the view is\nwritten, so the last write before unmount is saved; `shell.scheduleStateSave()`\nsays it changed; `shell.syncSceneBounds()` carries the moved bounds to the stage.\nNone of them renders a component.\n\nThe workspace half of a renderer's surface is one hook too\n(`renderers/workspace/useWorkspaceDocument.js`): `useWorkspaceDocument({ view, data })`\nreturns the live catalog entry of the prepared file, the prompt `resource`, the\n`services` object the shell takes and the host's commands;\n`workspaceLoadAlert` turns a catalog or loader failure into `load.alert`; and\n`useDeclinedSelectReference` consumes a host's request to select a reference in\na file that has none without changing the view. A renderer is then its\nscene hook, its tool list, its tools' rows and its sidebar sections, if it has any\n(`glb/GlbRenderer.jsx`, `mesh/MeshRenderer.jsx`, `robot/RobotRenderer.jsx`). The DXF renderer uses the workspace module without the\nshell: it has no scene, so it reaches the same catalog entry and commands directly.\n\nThe shell's behaviour has one real-browser test,\n`kit/shell/RendererShell.browser.test.mjs` (deferred files, warm reopen, isolated\nper-pane state, Preview's camera, gated Display sections, settings that never\nreplace the canvas), and the drawing tools have one scenario (`harness/drawScenario.mjs`)\nrun under the shell (`kit/tools/annotate/Annotate.browser.test.mjs`).\n\nThe STEP renderer is on the shell end to end: its scene and viewport\n([STEP scene and viewport](#step-scene-and-viewport)) and its surface, `StepSurface.jsx`,\nwhose frame, file view and host glue are the shell's. What STEP keeps of its own is\nwhat only a STEP has — its slices of the file's view (`workbench/stepViewSlices.js`,\nread and written by `workbench/useStepView.js`: the tree's expansion, hidden parts and\nisolated assemblies, the pose, the large-file setting, each behind the signature that says\nwhether it still fits the file), its prompt context, and the resource its live state reports while a rebuild is held\nbehind the previous mesh (`live.resource`). The surface composes modules of its own around\nthe shell: Measure's session (`kit/tools/measure/useMeasureSession.js`, the kit's), the pose and routines\n(`workbench/useStepMotion.js`), the context menus as pure descriptors\n(`file-view/stepMenus.js`) and what a selection or a node copies (`file-view/stepCopy.js`).\nThe shell was widened for it rather than forked: it accepts display settings a renderer\nmade earlier than it could hand them back (`viewSettings`), a viewer handle the renderer\nowns (`viewerRef`), externally held tool state (`tool`) and preview state\n(`preview`, from `usePreviewState`: STEP's picking and recognition gates run before the shell's hook),\nand a load report that knows about finding, editing and previewing.\n\nIts safety net is `step/StepRenderer.browser.test.mjs`, which opens a real `.step`\nin a real browser: a two-part component-SURF package with one revolute mate, one\nnamed pose and one routine, committed under `step/__fixtures__/step/` and served by\n`harness/stepScenario.mjs` exactly as the backend serves one. The same two shapes\nare also served as a package that really ARRIVES IN PIECES\n(`stageProgressiveFixture`): twenty-five components held one batch at a time, so\nthe loader publishes at 8, at 24 and at 25 and the MIDDLE publish is an in-place\nchange with nothing else moving. That is the only shape of load that can show what\n`viewport.commitScene()` is for — two components publish once, and a package whose\nsecond publish is its last is re-adopted by the viewport for reasons of its own.\n`kit/tools/annotate/Annotate.browser.test.mjs` covers the drawing scenario under the shell,\nand each renderer's own browser test asserts that the STEP-only Display sections\nare absent from it. The shell surfaces only STEP uses so far (the viewport menu and\nits open report, the camera-settled report, a scene\nthat arrives in place, the pixel ratio kept for hairlines, the runtime lifecycle,\nand what a renderer says about a load that is more than a download) are driven\nthrough `renderers/shell-harness` in `RendererShell.browser.test.mjs`.\n\n## DXF renderer\n\n`createDxfRenderer` (`@text-to-cad/ui/renderers/dxf`, id `dxf`) shows a `.dxf`. The CAD\nrenderer does not match one.\n\nIt is **not on the shell**, and that is the whole design: a DXF is a finished 2D\ndocument, so the pane is a canvas and the drawing is painted on it. No three.js\nviewport, no scene, no Display settings, no Preview. The questions the old\nMaterial/Bends tabs answered were about a sheet-metal part the viewer was inventing\nfrom the file; a drawing is not that. What it has is what a person needs to talk about\na drawing — the kit's chrome, composed around the canvas rather than around\n`ShellViewport` (`DxfRenderer.jsx`): the strip at the top centre, the tool's row under\nit, the file sidebar, Quick Edit at the top-right.\n\n- **Tools** (`dxf/tools.js`): **Select** (in hand; the view's hand too: a drag pans, a\n  tap picks), **Pencil**, **Shapes** and **Measure**, no view controls (Display, Projection and\n  Preview are a 3D view's). Select picks, front to back, a measurement, then ink, then\n  the drawing's ENTITY under the pointer (`drawing2d/entities.js`'s `entityAt`: within\n  6 px, a fingertip's 16; the lines over a hatch before the hatch; nothing on a hidden\n  layer); Shift adds. Its selection is the kit's (`useSceneSelection`,\n  `useSceneReferences`) over a drawing \"scene\" (`createDrawingScene`), lit over the\n  drawing (`drawEntityHighlights`). Its cursors are the 3D views': the arrow, pressed\n  or dragging too, the pointing hand over what a press picks. The drawing tools are the kit's\n  layer (`AnnotationOverlay` with a flat view's `surface`, `dxf/drawingAnnotation.js`):\n  ink lies on the sheet in drawing coordinates (x, y, 0), on no part, painted with the\n  drawing (`drawing2d/marks.js`); its row has no Placement. Measure is the kit's session,\n  overlay and chips (`useMeasureSession`, `useMeasureOverlay`, `measurementChips`) over a\n  plan-view camera (`drawing2d/planView.js`'s `planProjection`); a pick snaps\n  (`snapAt`) to an end, a circle's or arc's centre (`center`, \"Centre\"), else a point on\n  a line, and reads in the unit `$INSUNITS` declares — \"units\", never millimetres, when\n  it declares none. With Measure in hand its row under the strip lists its measurements\n  (`MeasurementsBar`), none while nothing is measured.\n- **References**: an entity by its DXF handle, `drawing.dxf#h2F` (`drawingReferences.js`);\n  everything a block reference draws is its INSERT, a dimension is one entity. Quick\n  Edit's chip names it by type and layer (\"Circle on CUT\", \"Block M5_NUT on HARDWARE\"),\n  its card the handle, type, layer, block, centre and radius.\n- **Layers**, a file-sidebar section, only where the drawing has the structure to read\n  (`layersSectionShown`): four or more layers drawn on, or any block reference. Each\n  layer's pen, name, entity count and eye (hidden: not drawn, picked or snapped to), and\n  under it the block references placed on it, a click picking one.\n- **Quick Edit** carries the picks and the ink (never the measurements: they are the\n  person's); its views are the window on screen as a plan-view camera (`planCamera`: looking\n  straight down, `up` +Y, `orthographicHalfHeight` the window's half height), which `cadgen dxf\n  snapshot --job` frames again, drawing the job's annotation over the drawing.\n\n- **The picture comes from the BACKEND.** `client.drawing(file)` is\n  `GET /__cad/drawing` (`apps/web/docs/backend.md`), asked again while the server\n  renders a drawing it has not drawn before: ezdxf flattens the modelspace on the\n  server — text placed, dimensions exploded, hatches filled, blocks placed — and the\n  client receives five primitive shapes and `text` in DXF coordinates, y up. **This\n  renderer never parses DXF.** The payload is cached server-side by the document's\n  content hash, so reopening a file costs a round trip and nothing else.\n- **Drawing is core's** (`@text-to-cad/core/lib/drawing2d`), so the headless snapshot\n  bundle paints the same picture from the same payload: `fitTransform` / `zoomTransform`\n  / `panTransform` (one uniform scale and a translation; the y flip lives in the\n  transform, not in the geometry), `prepareDrawing(payload)` (paths built ONCE, per\n  colour for strokes and per primitive for fills; text kept as strings) and\n  `drawDrawing(ctx, drawable, …)` (text set with `fillText`, stretched to the width the\n  server measured, so the page's face keeps the drawing's layout).\n  That bundle IS `cadgen dxf snapshot`: cadgen resolves a `.dxf` to the same\n  `cadgen.drawing_payload` this route answers with and the page fits and paints it\n  (`common/headlessDrawingRender.js`), so a CLI render cannot show what this pane\n  cannot. The CLI's option surface was cut to match — no display, no render mode,\n  `--appearance light|dark` and a size; a job may add a plan-view `camera` and an\n  `annotation` on the sheet, what a Quick Edit's views carry.\n- **Hairlines, always.** Strokes are 1.25 CSS px at every zoom, as AutoCAD draws with\n  LWDISPLAY off: paths are in MODEL space and the context carries the view, so one\n  `lineWidth = 1.25 / scale` per frame rebuilds no geometry. Model-space lineweights\n  are not displayed at all.\n- **`color: null` is the default pen** (ACI 7, \"whatever contrasts with the\n  background\"), resolved at DRAW time against the app's `--foreground` on its\n  `--background` — the same pair the 3D viewers' chrome uses. One payload therefore\n  serves both themes: flipping `.dark` repaints, it does not refetch. The tokens are\n  read off the pane, and a mutation on `<html>` schedules a frame. When they cannot be\n  read — and in the snapshot bundle, which has no stylesheet at all — the pair comes\n  from `@text-to-cad/core/lib/appTheme.js`, the one place both colours are written down.\n- **Fills are even-odd, and each one is filled on its own.** A `filled-paths`\n  primitive's inner rings are its holes; merging two overlapping regions of one colour\n  into a single path would turn their overlap into a hole as well. Fills go down\n  before strokes, which is the one ordering that never hides an edge.\n- **Interaction** (`dxf/useDrawingView.js`): fit on open and on resize until the\n  person moves the view, drag to pan (a primary press, one finger, once it has travelled\n  more than a tap's 4 px; a press that lets go before is the tool's tap), wheel or pinch to\n  zoom about the pointer, double-click to fit again. The view lives in a REF and the\n  canvas repaints through one `requestAnimationFrame` when something changed; a pan\n  never re-renders the component tree. A resize paints at once, inside the pane's\n  ResizeObserver (after layout, before paint): resizing the canvas wipes it, and a frame\n  asked for there would leave the pane empty for one frame\n  (`dxf/DxfResize.browser.test.mjs`). The backing store is DPR-aware\n  (`kit/viewport/pixelRatio.js`). What the tools draw (highlights, ink) is painted in\n  the same frame, after the drawing, so a capture holds it.\n- **Navbar**: nothing of the drawing's — no zoom buttons, because zooming a drawing is\n  the pointer's: wheel or pinch about it, drag to pan, double-click to fit. The host's\n  `captureRequest` is the canvas as a PNG, background included, delivered through\n  `host.promptContext`.\n- **Empty and failed drawings.** `bounds: null` (nothing in the modelspace) is a quiet\n  sentence over the empty pane, not an error. A non-200 becomes the ordinary actionable\n  alert card (`ViewerAlertCard`) carrying the SERVER's sentence (`failureAlert`,\n  `kind: \"http\"`); a payload from\n  a cadgen that disagrees about `schemaVersion` gets its own alert whose recovery is to\n  update cadgen and the app together.\n- **Host commands**: `resetCamera` fits the drawing again, `capture` hands over the\n  PNG, `clearSelection` lets go of the picks, and `readState` reports `camera: null`,\n  an empty `display`, the selection as references \n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: packages/ui/docs/feature-detection.md (Tokens: ~1778 | Category: anthropic-skill) -->\n# Client-side STEP feature detection\n\nFeature detection helps people navigate and select geometry in the shared Model\ntree. It belongs to the STEP renderer in `@text-to-cad/ui`; web and desktop run the\nsame code. It reads saved geometry through the injected CAD resource service.\nIt never reads model source, writes artifacts or executes a CAD kernel.\n\nKeep recognition independent of cadgen compilation, Python inspection and the\nreference grammar. A recognized feature is a group of existing canonical faces\nand edges, not a new kind of reference. Selecting or copying it uses the normal\noccurrence-scoped reference and prompt-delivery flow. Feature labels and inferred\noperations are not stable identities across document edits or recovered source\nhistory. Recognition is optional: structural rows, rendering and part selection\nmust work without it.\n\n## Demand and cancellation\n\n1. Load the lightweight assembly descriptor to display the hierarchy.\n2. The Model tree requests recognition for expanded, visible parts. Collapsed\n   parts need no recognition or exact-surface request just to show their rows.\n3. Resolve each requested occurrence to its component. Repeated instances share\n   component analysis while retaining distinct selection IDs and placement.\n4. Reuse completed recognition metadata when its exact geometry identity matches.\n   Otherwise obtain the component's SURF data through the resource provider and\n   analyze it in a disposable worker.\n5. Publish the result, cache successful metadata, then process the next requested\n   component. Each mounted inspector owns at most one active recognition worker.\n\nChanging expansion updates the queue without restarting an active component that\nis still requested. Switching between occurrences of that same component also\nkeeps its work. Removing the last requested occurrence cancels both pending\nsurface loading and worker work; queued components that are no longer requested\nare skipped. Taking the model tree off screen (choosing another tool, or entering\npreview), changing geometry or resource scope, and\nunmounting cancel the entire inspector's pending work. Cancelled jobs cannot\npublish late results or populate the completed cache.\n\nThis is deliberately a sequential queue per inspector, not a global worker pool.\nCompleted results are reusable between mounts; pending jobs are not shared\nbetween separate inspectors. Retry clears failed results and queues those\ncomponents again without cancelling other components that are still needed.\n\nLoading is lazy **per component**: expanding a part analyzes that component as a\nwhole, not only the feature rows subsequently opened. Recognition neither expands\nother parts nor blocks the initial structural tree. The stable top row counts\npresented top-level features; it does not display global recognition progress.\nSelection, isolation and reveal rules remain in the\n[Model tree contract](cad-renderer.md#step-panels).\n\n## Cache identity and lifetime\n\nThe completed cache key combines:\n\n- The resource provider's workspace/generation scope.\n- `MODELING_RECOGNITION_VERSION`.\n- Exact surface input and object identities, or a provider-scoped immutable SURF\n  URL for older static packages.\n\nDo not key recognition by filename, display label or assembly occurrence. On a\nwarm reopen, accepted component identities can avoid surface requests altogether,\nbut only after workspace, provider generation, entry revision and runtime descriptor\nmatch. Changed geometry or scope must never reuse the previous binding.\n\nThe least-recently-used cache holds at most **512 components / 8 MiB** of accounted\nserialized metadata. Each read returns a private copy. It retains no SURF buffers,\nworkers, scene objects, pending promises or failed results. The active inspector\nalso retains its own results, including errors for explicit retry; disposing it\nreleases that local state. Large or evicted results may require recomputation.\n\nThis cache is **in memory**, separate from cadgen's persistent surface/mesh store\nand host preference storage. Refreshing the page or restarting the app loses it.\nThere is no recognition sidecar, disk database, compile step or backend endpoint.\n\nAdvance `MODELING_RECOGNITION_VERSION` in\n[modelingRecognitionCache.js](../src/renderers/step/workbench/modelingRecognitionCache.js)\nwhen inference rules, tolerances or serialized results change, including returned\nlabels, measurements and face/edge grouping. This is an internal algorithm\nrevision, unrelated to the repository's release version. Presentation-only\nchanges outside cached results do not require a bump. Geometry and resource\nrevisions remain independent parts of the key; the algorithm version replaces\nneither. If configurable recognition options are introduced, their semantics\nmust also participate in the key.\n\n## What the recognizer infers\n\nThe current algorithm first tries a complete extrusion, revolution or supported\nsolid reconstruction. Otherwise it presents an imported body with local feature\ncandidates and remaining geometry. That precedence is a presentation heuristic:\nthe same circular cut can appear as a cut extrusion or a bore in different\nbodies. Changing these classifications is separate from cache/lifetime work.\n\n- **Extrusions:** paired analytic cap contours, straight walls, side area and\n  solid volume support a possible base/cut sequence. Profiles select real STEP\n  boundary edges.\n- **Revolutions:** full coaxial cylinder/cone solids can yield a closed axial\n  profile and 360-degree revolution checked against solid volume.\n- **Pockets and bosses:** local planar caps, translated rims and straight walls\n  identify supported prismatic regions. Current local tests cover convex analytic\n  profiles; they do not cover every pocket or boss.\n- **Bores:** adjacent cylindrical patches are joined across export seams.\n  Annular shoulders and full conical transitions can connect stages into blind,\n  countersunk or counterbore candidates. Boundary samples and enclosed face\n  bounds reject evident intrusions; they do not prove an empty volume.\n- **Blends and other supported solids:** connected constant-radius\n  cylindrical/toroidal blends, rounded boxes and supported ruled lofts have\n  analytic recognition paths. Single curved corners are not automatically\n  separate fillet operations. Shells and patterns are not invented.\n\nUnclaimed faces remain available as other geometry. Candidate ordering is not a\nmodeling timeline, and a missing candidate does not establish that a feature is\nabsent. Measurements describe supported geometry; recognition is not engineering\nvalidation. Independent kernel reconstruction checks belong to development tests,\nnot the runtime viewer or Python inspection API. Complete reconstruction claims\nrequire both directional Boolean differences to agree; finding a local candidate\nor matching its tool volume alone does not establish a rebuildable whole part.\n\n## Limits and failures\n\nThe worker ticket permits at most 16 MiB of SURF data. Analysis rejects inputs\nover 6,000 faces or 20,000 edges; the worker/ticket stage has a 10-second timeout.\nThese are safety limits, not promised execution times. Surface derivation before\nworker startup has its own resource-service lifecycle.\n\nUnavailable data, unsupported-size inputs, worker errors and timeouts produce a\ncomponent error and explicit retry. They do not become reusable cached results.\nOther requested components can still be processed. Existing ordinary geometry\nand part selection remain available. Keep workers off the UI thread, preserve\nthese bounds, and profile large parts before adding persistent caching or more\nconcurrency.\n\n## Implementation and verification\n\n- [useModelingRecognition](../src/renderers/step/workbench/useModelingRecognition.js):\n  descriptor demand, per-inspector queue, resource requests and cancellation.\n- [modelingTree.worker](../src/renderers/step/workbench/modelingTree.worker.js):\n  resource-ticket loading, SURF parsing and inference.\n- [modelingTree](../src/renderers/step/workbench/modelingTree.js) and adjacent\n  `modeling*` helpers: pure inference and canonical reference mapping.\n- [modelingPresentation](../src/renderers/step/workbench/modelingPresentation.js) and\n  [ModelingTree](../src/renderers/step/components/workbench/ModelingTree.jsx):\n  occurrence binding, presentation, expansion and selection.\n\nKeep renderer-private pure helpers here; they do not need to move to core merely\nbecause they do not import React. Hosts implement resource effects through the\n[viewer host contract](viewer-host.md), never a second recognizer.\n\nRun `npm --prefix packages/ui test` from the repo root for helper and React\ncoverage. Recognition tests cover real STEP-derived fixtures, version/geometry\ninvalidation, cache bounds and warm reopening, expansion during pending work,\nrepeated instances, retry, disposal and rejected late results. Fixture provenance\nlives in the [fixture guide](../src/renderers/step/workbench/__tests__/fixtures/README.md).\nAdd small test-owned regression fixtures for changed inference rules. Rebuild\npackage exports and the bundled viewer before testing either app.\n\n<!-- File: packages/ui/docs/file-viewer.md (Tokens: ~2019 | Category: anthropic-skill) -->\n# FileViewer\n\n`@text-to-cad/ui/file-viewer` exports the complete file view: one file by its\nabsolute path, under the navbar, with the explorer its name opens, renderer loading,\nand the loading and error pages.\nIt imports no concrete renderer. Applications compose registrations from the\nseparate `@text-to-cad/ui/renderers/*` entry points and their own\n`defineFileRenderer` definitions ([renderers](renderers.md)). A CAD host composes\n`CadViewer` (`@text-to-cad/ui/cad-viewer`), which is this component with the five\nCAD renderers, the home and the standard error pages ([viewer host](viewer-host.md)).\n\n```tsx\n<FileViewer\n  file={selectedPath}   // absolute; null is the host's home\n  host={viewerHost}\n  renderers={registeredRenderers}\n  state={viewState}\n  onStateChange={persistViewState}\n/>\n```\n\nSee [ViewerHost](viewer-host.md) for required host services and prompt delivery.\nCreate sources and registration arrays at their owning workspace or tab\nlifetime. Changing their object identity cancels outstanding document work.\n`source.id` is a stable identity, independent of a server's temporary port. Every\npath is absolute and `/`-separated (`/a/b.step`, `C:/a/b.step`); containment and\nauthorization remain in the host service. The host controls navigation\n(`navigation.openFile(path)` where the view can show another file, and `home()` where it\nhas a home; a view that shows one file alone has neither, and no explorer) and keeps\n`FileViewerState` in its tab store (`@text-to-cad/ui/tab-store`; `useTabViewerState`\nderives it): `renderers`, under encoded `[file path, renderer ID]` keys, each the\nfile's view.\n\nA source reads files and never writes them. `stat(path)` answers a file's metadata;\nwhere its files can be browsed, `list(directory)` answers a folder's entries and\n`search(directory, query)` the files under a folder whose path below it holds the\nquery, saying when it stopped early. What the file menu offers is the host's\nseparate `fileActions` capabilities. Sources receive an `AbortSignal` for each read;\nnoncancellable reads still check the signal before returning. A renderer that\nacquires a resource (a URL lease, a worker) returns its release as the prepared\ndocument's `dispose`; FileViewer calls it on navigation, reload, cancellation and\nunmount.\n\nUse `defineFileRenderer<T>` to keep the prepared payload paired with its lazy\ncomponent. A definition supplies `id`, numeric `priority`, `matches`, `prepare`,\nand `load`, and optionally `fallback`. Larger priorities win.\nDuplicate IDs and equal-priority matches\nproduce explicit errors. At most one registration may declare `fallback: true`;\nit runs only when no ordinary registration matches. Registration construction\nand matching do not load the component.\n\n`prepare` returns `{ data, dispose? }`. FileViewer opens a file's document when the\nfile is shown, and again only on `reload`: a renderer follows its file's changes\nitself (every CAD renderer loads the next revision behind what is on screen). A CAD\nfile's controls are its own, over the viewport: its tool strip, the tool in hand's\nsub-toolbar, and its file sidebar\n([the design system](settings-ui.md#the-file-sidebar-and-the-tool-stack)); the view's\ncontrols (Display, Preview) are the navbar's, last at its right end.\n\nBelow 600px of its own width FileViewer is mobile (`useViewerMobile`). The width is a\nbreakpoint boolean, never a pixel value, so a resize within one layout does not\nre-render the renderer.\n\nA renderer is handed, besides its document: `onNavigationActionsChange`, for its\nnav-row actions (`FileNavigationAction`, with an optional shorter `hint`), hidden\nwhile it reports `onReady(false)`; `appearance`; and `onOpenFile(path)`, which shows another file, by\nits absolute path, in this view (a robot's mesh), absent where the view shows its file alone. Its loading and update status are its\nown to show: a CAD renderer shows them in its viewport. Host-specific pages are supplied through `presentation`,\nwithout duplicating the view's placement: `home` is a host's own page for a view with\nno file (every CAD host's model library), under the navbar; `loading`; and\n`error({ message, missing })` for a file that will not open, `missing` when it does\nnot exist. Per-file renderer state is also accepted during a departing renderer's\ncleanup, while it still belongs to the same source.\n\n## The navbar and the explorer\n\nThe navbar is drawn over every file from the moment one is asked for, and over the\nhost's home — only its logo and menu, without Back to files, Back and Forward where the\nhost keeps the view's history, and the host's update and Full size, where they stand over\na file — never for a `compact` view (a\npicture: the home's thumbnails); the CAD viewer's Preview keeps it. Over a file, left to right:\n\n- the text-to-cad \"C\" logo (named Menu, with no chevron), which opens the app menu\n  (`navigation/AppMenu.jsx`): Back to files where the host has a home\n  (`navigation.home`), the person's settings (`appSettings`), Send feedback, GitHub,\n  Discord and, in gray, the version and who made it. FileViewer builds it from\n  `host.links`, `appSettings` and `environment.platform`. A host with no home has the\n  logo and the menu too, without Back to files;\n- Back and Forward, where the host keeps the view's own history (`history`, a\n  `ViewerHistory`: the CAD app's views; a browser's page has its own and hands none): two\n  arrows, each there always and disabled where it has nowhere to go;\n- the open file's name: a button that opens the explorer where the source can both list\n  and search, and plain text otherwise;\n- the ⋯ file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host's `fileActions` can do it, and no ⋯ where it can do\n  none.\n\nThe logo, Back and Forward, the name (where it opens the explorer) and the ⋯ are one kind of control: transparent\nat rest, the same accent background while the pointer is on it and while its menu is open, a\npointer cursor, and no tooltip.\n\nAt the right, only what a host or a file adds: the host's update button, a renderer's\nactions and the host's Full size, where the host shows the view small. A renderer's\nown view controls (a 3D view's Display and Preview, or Preview's settings and Exit preview)\ngo last, in the box FileViewer hands it (`navbarSlot`, `data-view-controls`).\n\nThe explorer (`navigation/FolderExplorer.jsx`) is a popover under the file's name, a\nfixed 18rem wide and never resized. It floats over the view, so opening it resizes\nnothing, and it is built anew on each opening, so it starts in the open file's\nfolder, its filter taking the keyboard (on a phone the explorer holds focus itself, so no\nkeyboard comes up). It lists one folder at a time through `source.list`: the folder's\nsubfolders, then its CAD files, the open file highlighted, in compact rows (a folder's glyph\nits disclosure chevron). A press on a subfolder opens it inline, under itself and a little\nfurther in, reading it through `source.list` the first time; a double-click opens it in the\nexplorer's place. A breadcrumb shows as many of\nthe last three folders of the one it is in as fit whole, measured before it is painted:\na crumb is never cut, the farthest giving way first to a \"…\" button whose menu lists the\nfolders above the crumbs to jump to, and only the folder it is in, alone beside the \"…\",\nis cut to the row. A crumb, as a double-click does, opens a folder in place, and there is\nno up arrow. \"Filter files...\" searches every file nested under the folder it is\nin through `source.search`: the client waits 150 ms after the last keystroke and\naborts the search before it, and each match shows its path below the folder, the\nsubfolders muted before the name. An answer that is `truncated` adds \"Stopped early.\nKeep typing to narrow the search.\" The viewer server bounds both reads\n(`GET /__cad/folder`, `/__cad/search`): hidden and build folders are left out of each,\nand a search stops at 200 results, about 1.5 s or depth 24, walking a folder it reaches\ntwice once. A pick opens the file through `navigation.openFile` and closes the popover.\nUp and Down move between the filter and the rows; Escape closes it like any popover.\n\nThe browser harness beside FileViewer exercises injected renderers, source and file\nchanges, navigation actions, the explorer and the file menu, the mobile breakpoint,\nand resource disposal. Run it with the package test command after building the shared\npackages. Chromium must be available for the browser tests.\n\n<!-- File: packages/ui/docs/lod.md (Tokens: ~3346 | Category: anthropic-skill) -->\n# Level of detail, memory, and live updates\n\nWhat the viewport does in the background between \"a file was selected\" and\n\"the picture has settled\": which tessellation it starts from, how it refines,\nwhat it refuses under memory pressure, and how it follows a model that is\nbeing rebuilt while you watch.\n\nAll of it is the STEP package path of the STEP renderer. A triangle mesh (STL, 3MF) and a\nGLB have no levels: their renderers load the file whole, parse it once per revision (an\nSTL in a worker) and keep the decode cached, so reopening one fetches and parses nothing\n(see [Mesh renderer](cad-renderer.md#mesh-renderer)).\n\nThe observable promises are in [the web app README](../../../apps/web/README.md#the-laws-that-bind-the-app);\nthis file is the mechanism behind them. None of it may change **exact**\ngeometry, measurements or explicit mesh-export tolerances — detail is a\ndisplay concern only.\n\n| § | Covers |\n|---|---|\n| [1](#1-where-a-model-starts) | Coarse-first admission, cached standard meshes |\n| [2](#2-what-refinement-samples) | Camera sampling, offscreen components, hysteresis |\n| [3](#3-admission-and-memory-pressure) | Budgets, reservations, coarsening caps |\n| [4](#4-the-replacement-batch) | CID caps, the first-ready deadline, adoption receipts |\n| [5](#5-cancellation-failure-and-cleanup) | Parked targets, restoration, ownership |\n| [6](#6-following-an-active-build) | The edit feed, what the viewport shows, the status heartbeat |\n\n## 1. Where a model starts\n\nAssemblies with at least 64 unique components can start at a coarser display\ntessellation when standard meshes are not cached. Cached standard meshes are\npreferred immediately, subject to their probed decode size and admission. The\ntiers are probed a chunk of components at a time and the cached bodies read a\nbatch at a time (`createInitialDisplayPlans`, `packageBatchReads.js`), the first\nof each the size of the first publish, so the first geometry waits on no more\nthan it draws.\nSmaller assemblies start at the standard level, except that an individually\noversized component may start coarse. A component above the concurrent decode\ncap runs alone only when the shared Viewer memory envelope can reserve its\ncomplete estimate.\n\nCoarse geometry is a temporary preview: visible components automatically reach\nat least the standard level, preserving its angular smoothness even when\nprojected chord error alone would permit a coarser mesh. Close inspection can\nrequest finer detail, and so does preview, which the STEP renderer draws one\nscene-quality tier up (`kit/viewport/previewViewScene.js`) without changing the Display\nsetting: while Preview is open the loader samples Preview's camera and asks at its\ntier, both scenes reconcile the same published mesh, and only the editor's adopts it. The chrome does not report detail levels: the STEP renderer\nrecords first geometry and standard detail for benchmark harnesses only\n(`window.__cadViewerQuality`, `useViewportQualityStatus`), and background file\nwriting stays quiet. That record lives in the STEP surface, so it takes a scheduler\nsnapshot only when the status it derives would change (`lodSnapshotStatusKey`): a\npreview orbit resamples the camera on every frame, and a snapshot per frame would\nre-render the whole surface per frame — while a model streams in, redoing the\npartial model's derived state each time.\n\n## 2. What refinement samples\n\nRefinement uses the camera and the disposable memory budget.\n\nStatic assemblies sample full transformed occurrence bounds against the camera\nfrustum, refining a component when at least one occurrence is on screen.\nOffscreen components stay displayed: ordinary camera sampling retains their\nexisting detail, and memory pressure can coarsen them before visible\ncomponents. Unknown or not-yet-adopted bounds remain eligible.\n\nA stationary camera requests the final level implied by the existing hysteresis\nthresholds directly. If admission refuses that level, strictly intermediate\nlevels can supply measured replacement sizes for another try.\n\n**Resampling a motionless viewport is not work.** A sample carrying the same\ncamera, viewport and per-component distances, visibility and selection neither\nrestarts the settle debounce nor republishes status, so a viewport nobody is\ntouching reaches settled quality and stays there. An idle scheduler reports\nmemory-limited targets separately from settled quality. Camera and distance\ncomparisons ignore floating-point noise at a 1e-10 relative tolerance, measured\nagainst the last meaningful sample so accumulated movement still triggers work.\nVisibility, selection, quality changes and explicit retries remain meaningful.\n\nScenes with joints, embedded animation, drawing poses or an active/collapsing\nexploded view keep conservative eligibility, including paused or disabled pose\ncapabilities. Authored visibility and material flags are not LOD filters.\n\n## 3. Admission and memory pressure\n\nFailed loads stay parked; denied admission retries only after the displayed\nlevel or camera intent changes. Pressure-driven coarsening caps subsequent\nrefinement until the camera or viewport changes, preventing upgrade/downgrade\nloops. Mesh-bound and clip-plane updates do not reset that cap. Pressure\ncoarsening remains singleton.\n\nAdmission can reclaim idle tessellation workers and retry while preserving\nactive consumers. Its ledger samples each live worker's own retained estimate\nbefore admission, so a large component does not inflate every worker's charge.\nRefinement reserves both replacement arrays and worker scratch space, and\nincludes the coarse tier's relaxed angular tolerance in its estimate.\n\nDisplay arrays shared with asset caches have one CPU charge for the entire\nbacking allocation, including unused sections of packed buffers. GPU charges\nuse uploaded view sizes; CPU-only edge inputs and picking allocations are\naccounted for separately.\n\nA component that cannot fit even at the coarse level reports a limitation and\npreserves the current view. **Estimates and sampled resource totals are a soft\nbudget, not a hard browser RSS limit.**\n\n## 4. The replacement batch\n\nThe scheduler holds at most four distinct replacement CIDs across loading,\nready payloads and actual scene adoption. Its render and late-selector\npreparation share one loader lane, and only one atomic mesh/reference\npublication awaits adoption.\n\nThe Viewer uses a 128 ms first-ready collection deadline so serialized cached\nreads can fill the four-component batch. It may publish a ready subset beside\none unfinished carryover; it does not guarantee selector, worker or scene\nreadiness. No fifth replacement starts. Admitted refinements keep filling the\nbatch while exact sibling reservations allow it, even after the coarse pressure\nthreshold is crossed; a denied reservation flushes the ready subset.\n\nSeparate user-driven topology requests keep their existing worker admission and\ncache/picking accounting; they are not included in the scheduler's occupied-CID\ncount. Topology-only interactions also release idle workers after their sibling\nrequests drain.\n\nActual payload backing allocations are reconciled before another sibling is\nadmitted. Temporary sibling-capacity denials flush and retry after ownership\nchanges; they do not permanently park a target. Displayed levels and measured\ncurrent sizes remain unchanged until the complete exact batch adopts.\n\n**Adoption receipts.** Replacement admission stays held until the viewer adopts\neach current component payload at every occurrence and accounts for its scene\nownership. This acknowledgment schedules rendering; it is not a GPU\nupload-completion fence, and modeled upload ownership remains separate. A\nsuperseding progressive publication can satisfy it only with the same context,\nrevision, occurrence set and exact payload. Display geometry and demanded\nselectors publish as one matching state pair, with commit receipts fencing\nabandoned or replayed React updates.\n\nEvery publication lands in the ONE STEP scene the viewport adopted\n(`renderers/step/scene/stepScene.js`): while the structural build key holds for\nthe same model, the live build reconciles its records in place, and the viewport\nis told the scene changed (`viewport.commitScene()`) rather than handed a new\nscene — so a progressive open or a detail swap never re-dresses every material or\nre-adopts anything. The camera frames the model once, on its first publish, on\nthe box `assembly.json` declares for the whole of it (`bbox`, carried as the\ncomposition's `declaredBounds`): the scene's `restBounds` is that box from the\nstart, so the framing, the orbit pivot, the zoom ruler, Zoom to fit, preview's\nturntable and the ground's size are final before most components have arrived,\nand later publishes move none of them. A descriptor without a box leaves the\nscene `complete: false` until the last component is in: it is framed on what\narrived first and once more when it is whole, unless the person has taken the\ncamera by then. The camera sample that drives all of this is taken when the\nviewport says the camera settled (`onCameraSettled`: a move, a preview orbit, or\na resize, which can expose a part without moving the camera) and when the\nselection changes.\n\nA static component publication can reuse the main adoption's completed reset\nonly in that same React render. Later visual or clipping changes still run\nnormally, as do transitions out of modules, animation, drawings or poses.\n\nProgressive display and later detail swaps share unchanged occurrence rows and\ntree metadata; changing tessellation alone does not rebuild every tree leaf.\nPlacement, appearance and changed bounds still update their records. Selection\npruning preserves unchanged selected, referenced and hidden ID arrays,\npreventing detail publications from retaining historical workspace render\ncontexts through unnecessary selection updates.\n\nDisplay raycast accelerators are requested only when a picking ray reaches\ncomponent bounds, then queued during idle time for one worker at a time.\nAdmission covers private input copies, worker scratch and the returned tree;\ndisplayed arrays stay attached and unchanged. Releasing the last geometry owner\ncancels its build, and stale results cannot attach to replacement geometry. The\nfirst pick remains exact and may cost more on a dense component; merely loading\nor refining an assembly does not build an accelerator for every component.\nInputs with a separate merged face-selection proxy still build that proxy's\naccelerator on the main thread during idle time. Canonical STEP selectors use\nthe display meshes and do not enter that separate path.\n\nDiagnostic snapshots identify scheduler-only ownership, batch sizes and seal\nreasons. The internal size-one control uses the same admission/publication path\nas groups of four.\n\n## 5. Cancellation, failure and cleanup\n\nCancellation requests cleanup: switch, abort or unmount retains an outstanding\nreservation until actual replacement, restoration or complete disposal proves\nthat the renderer has released its previous owner. Pending component maps\nremain separate from adopted maps.\n\nA failed scene update clears its partial records before rebuilding the last\nadopted mesh/selector pair; it never reconciles against already-disposed\nrecords. Restoration preserves unrelated progressive components and completed\nselector loads. A second construction failure stops detail work and reports an\nerror. Cleanup failure keeps ownership charged until a real cleanup retry\nsucceeds.\n\nA cancelled batch that actually adopted remains a displayed payload owner even\nthough its scheduler levels are not promoted, so cancellation does not evict\nits exact cache entries.\n\n## 6. Following an active build\n\nSTEP entries always follow active edits, showing the root preview before its\nSTEP save. Run the model normally; existing decorators need no new imports, and\nthe daemon must be running for live updates.\n\n**The feed.** Updates arrive through a held request that wakes when this\noutput's build ledger changes. Unrelated jobs do not wake the tab. The server\nadmits 32 waiters independently of kernel workers; excess tabs retry every\n500 ms. A server that answers at once instead (a host relaying requests through a\nfew slots all its views share, where a held request would take one) is paced by the feed: an answer\nwith nothing new waits out the rest of a second, and news is asked after again\nwithin a tenth of one. An idle heartbeat revalidates saved bytes and missing geometry; closing\nor switching the tab cancels the request. Older status responses cannot\noverwrite newer cached progress, and saved revisions are verified from one\ncoherent file snapshot. Restarting the daemon expires the ephemeral session,\nand rerunning the model reconnects it.\n\n**What stays on screen.** The prior model stays visible while the next request\nbuilds; failed updates remain visible while an idle disconnected feed retries\nquietly. A complete model of the same file — a part's or an assembly's — also\nremains visible while its rewritten file is rebuilt and while the replacement\nmeshes load (`replacingSameFileMesh`, `awaitingSameFileRevision`), reported as an\nupdate (\"Updating model…\"), never as the loading screen — also when edits come\nfaster than revisions load, and one revision's load is cancelled for the next\n(`meshStateAfterCancelledLoad`). Complete displayed component arrays remain available while a\nreplacement stages or fails. Reuse requires the same runtime surface input,\nconcrete surface object and tessellation; placements and appearance come from\nthe new tree. Selection, measurements and reference copying wait for matching\nnew geometry. A failed replacement preserves the view and reports its error;\nonly that file/hash stops retrying automatically. A first load that fails part\nway keeps the parts it drew under its error, and refinement goes on over them: a\ndetail swap changes geometry only, never whether the load finished or failed\n(`detailSwapMeshState`). STEP pose and animation\nmetadata use their normal loading path, without a promise to retain the\nprevious pose. Snapshot source isolation is unchanged.\n\n**Overlapping loads.** Geometry and reference loads own their cancellation\nindependently; a superseded request cannot cancel its replacement. Topology\nrequests for one file revision union rather than supersede: a batch in flight\nfinishes, and one no longer wanted is simply not published.\n\n**After a save.** The view shows the saved file. When a build writes it, the new\nrevision replaces the one on screen in place, keeping every component whose identity\nit shares; while the build runs the model on screen says it is updating, and a failed\nbuild leaves it on screen under its alert.\n\n**What the viewport shows.** The navbar carries no status. Opening and\nupdating are the viewport's loading overlay (`ViewerLoadingOverlay`). A failure is\na card over the viewport (`kit/status/ViewerAlertCard.jsx`) with its explanation,\nits next step and the full diagnostic under Details: an error always shows, and a\nfailed update the model survives (`blocking: false`, the previous version still on\nscreen) has a Dismiss button and stays dismissed until the alert changes, or\nclears and is raised again. A warning is a card too, one that can be dismissed\nwhile the model is on screen: the card is the one place a problem is said. Once a\nusable current view is displayed, saving, successful completion, idle edit-feed\nstate and routine refinement stay quiet. A sidecar this build cannot read raises\nno alert: the model renders with no kinematics, no materials and no routine, and\nthe migration is announced where it can be acted on — the build and the cad\nskill. Existing usable views remain visible during updates and failures.\n\n**Opening.** Opening shows one step line — **Finding file**, **Reading\nmodel**, **Loading geometry**, or **Preparing view** — with no headline above\nit; the loading mark itself says a model is opening. Counts measure\ncompleted geometry items in the current stage, not assembly occurrences or an\noverall ETA; uncounted stages are indeterminate. Render initialization uses the\nsame indicator against the destination backdrop until its first usable frame.\nLong waits show elapsed time; interrupted progress explains that the viewer is\nwaiting for a response before offering recovery. Selection and edge preparation\nreport beside their controls, not as whole-model loading.\n\n<!-- File: packages/ui/docs/render-mode.md (Tokens: ~3232 | Category: anthropic-skill) -->\n# Display modes\n\nThe Viewer and snapshots share one strict sparse contract. Core's `common/viewSettings.js`\nvalidates it and resolves the mode, or Preview's Render, into renderer groups.\n`common/sceneSettings.js` translates those groups into the scene recipe. The binding\ncontrols are in [settings-ui.md](settings-ui.md#display-settings-and-section-primitives).\n\nA file's display is a mode and the Grid switch. A mode is a look at its own defaults, with\nnothing to set. Render is no display mode: it is the photographic look Preview and a\nsnapshot's `--preview` ask for on their own, over the same display, with Preview's own\nsettings (the viewer's alone, kept beside the display).\n\n| Mode | Surface / edge recipe |\n| --- | --- |\n| Solid (default) | Shaded, the model's visible CAD edges |\n| Shaded | Shaded, no edges |\n| Parts | Solid, each part its own palette colour |\n| X-ray | 22% opacity, all edges |\n| Hidden line | Depth-only surfaces, visible edges (light on a dark backdrop) |\n| Wireframe | No fill, all edges (light on a dark backdrop) |\n| Render (Preview's, asked for on its own) | Shaded, no edges, no grid, photographic studio |\n\nA file without CAD edges (STL, 3MF, GLB, a robot) offers Solid, drawn without edges, and\nParts where it has several parts, objects or links; Shaded would be Solid again there. The\ngrid is off by default and the same measuring grid in every mode. Appearance, camera, Clip\nand Explode are shared. Camera defaults to orthographic with a 50mm lens; Render resolves to\nperspective without overwriting that shared camera choice.\n\n## Sparse public contract\n\n```json\n{ \"mode\": \"parts\", \"grid\": true, \"appearance\": \"light\", \"camera\": { \"projection\": \"orthographic\" } }\n```\n\nOmitted mode is Solid, and `mode` is one of the six; `grid` is a boolean, off unless said.\nThe display also takes `clip` and `exploded`. Anything else is refused: there are no\nper-mode settings, style groups or axes. Preview's own settings (`parts`, `lighting`,\n`background`, `floor`) are a separate sparse object (`normalizePreviewSettings`): `parts`\ncontains `colorMode` (`original`, `single`, `by-part`), `color` and `colors`; `lighting`\ncontains `quality`, `exposure`, `rotation`, `size`, `fill`; `background` contains `color`,\n`opacity`; `floor` contains `enabled`, `placement`, `color`, `opacity`, `finish`.\n\nViewer appearance inherits the host; snapshots default to light. Lighting quality inherits\nPreview in a viewer and Final in a snapshot. There is no Custom state, legacy alias or\nmigration. A stored display or Preview settings record is read setting by setting at the\nfile-session read boundary (`readViewSettings`, `readPreviewSettings`): what this build\ncannot read (an older per-mode tree, a mode it no longer has) is left out, the rest kept.\nThe same display shape powers live `setDisplaySettings` and snapshot `--display`; either\nrefuses `mode: \"render\"` as it refuses any mode it does not know. A snapshot turns the grid\non with `--grid` (`display.grid`) and asks for Preview's look with `--preview` (a job's\n`\"preview\": true`, the page's own flag), at Preview's defaults. `--camera` holds\npose/framing only. A Quick Edit's views carry the mode and the grid (`display.mode`,\n`display.grid`) with Clip and Explode, so a snapshot of them replays the view.\n\n## State and updates\n\n`kit/view-settings/viewSettingsStore.js` owns one sparse display, and Preview's settings,\nper mounted file. `selectMode(mode)` selects a mode; `setGrid(on)` switches the grid (off\nis left unsaid); `patch(patch)` merges the display's shared fields. `patchPreview(patch)`\nmerges Preview's settings and `resetPreview()` removes them. `restore(display, preview)`\nreplaces state once on mount; persistence reads the latest `getSnapshot().display` and\n`.preview`. `renderScene()` resolves Preview's scene without committing anything.\n\nNo action reframes the camera, changes model pose, selection or visibility. The store\npreserves equal JSON branches; a Preview edit leaves the display's scene identity unchanged.\nClip edits do not invalidate materials, lighting, geometry or camera. No-op actions publish\nnothing. Do not merge captured props, persist resolved defaults or keep a second display\nstate.\n\nThe view injects its offered `modes` and `settings` (`edges`, `clip`, `exploded`). Unsupported\nsaved modes resolve to Solid without rewriting storage; unoffered settings do not affect the\nscene. The shared UI discovers no host APIs.\n\n## Rendering costs and camera behavior\n\nThe photographic rig stays lazy. Preview's Render creates its photographic\nsoftbox environment; the other modes use neutral CAD lighting. Authored materials are read-only. Color\noverrides are presentation settings, not edits to the model or its material\nassignments.\n\n| Lighting quality | Screen error | Shadow map | Environment | Capture scale | Samples at rest |\n| --- | --- | --- | --- | --- | --- |\n| Preview | 1 px | 2048 px | 256 px | 1× | 32 |\n| Final | 0.25 px | 4096 px | 512 px | 2× | 64 |\n\nRender is drawn through the studio's frame (core's `studioFrame.js`, see\n[the render pipeline](../../core/docs/render-pipeline.md#the-studios-frame)): ambient\nocclusion from the frame's depth, and, while the view rests, a picture that converges over\nits samples: anti-aliased, its occlusion smooth, the key's shadows a softbox's (Softbox size\nsets how soft). The viewport's loop (`useViewerRuntime`) draws each frame through it and says\nwhether anything but the camera changed (`runtime.requestRender`/`requestFrame` say so); while\nsamples are owed it asks for the next frame itself, and once the picture has converged it\ndraws nothing more. A drag, a routine's frame or a setting starts the picture again; the\ndamped tail of an orbit, moving the model less than a quarter of a pixel, does not. So\norbiting pays one draw and a few small passes a frame (about 1.5 ms more at 3024 × 1964 on\nan M1 Max) and a still view converges in about half a second. A snapshot draws its still's\n32 samples at once (video frames one). Software WebGL has no frame: one draw, as before.\n\nEach interactive viewport keeps its renderer (a view of the page's one GPU device:\n[the GPU device](cad-renderer.md#the-gpu-device)), canvas and camera controls\nacross every mode and settings edit. It uses shadow-compatible conventional\ndepth throughout, fitting near/far planes to the current model, closeup records\nand floor/grid planes on every frame; a perspective near plane stays at or beyond\n1/256 of the orbit pivot's depth, so a closeup never trades depth resolution for\na near plane at the eye. The grid's bounds also fit the far plane;\nit is independent of the studio floor used by Render. The grid\n(`lib/viewer/stageGrid.js`, one shader quad the viewer and the snapshot both draw) is graph\npaper on the floor, after Tinkercad's workplane: thin minor lines at a round step (1, 2 or\n5 x 10^n) near twenty across the default framing at density 1, a heavier major line every 5 or\n10 of them, in one cool cyan-blue ink (`GRID_LINE_DEFAULTS`: `#4ea8cc` at 0.45 on a light\nbackdrop, `#5d9ac0` at 0.35 on a dark one; minors are the same ink at 0.6 of its strength), with\na red X and a green Y axis through the origin, anti-aliased in the fragment shader. Each family\nof lines fades only once its cells crowd under about 3.5 pixels, so there is no moire zoomed\nout or at a grazing angle; the sheet is at full strength to 1.5 framings from the origin, past\nthe default view's corners, and dissolves gently by 6, and fades a little as the plane turns\nedge-on, so it reaches the edges of the view yet has no edge of its own. Display has no separate Axes setting. The grid, the stage and the Render studio's floor are\nSIZED from the model's rest placement for every renderer, STEP included: a pose, a\nplaying routine, or a pose set in Preview never rescales or\nslides the ground; its height, the lighting and the shadow reach follow the model. Enabling photographic effects updates that live scene;\nit never clears or covers the canvas with a loading screen. Only actual context\nrecovery replaces the renderer. Quality never changes a mesh: every mode draws\ncadgen's one display mesh. Picking/topology stays demand-driven in every mode.\n\nEnabling Clip prepares its shader/cap variants before presentation. Moving an\nenabled cut stays live. Explicit neutral boundaries\nstill avoid unnecessary caps. See [responsive View updates](view-updates.md)\nfor scheduling, resource caching, loading presentation and capture readiness.\n\nRender's floor stands at the document's Z=0 plane (Model origin), at 60% opacity, its opacity\nletting geometry below that plane show; Lowest point is a choice away and is remembered in Render, as Glossy is. A snapshot's Render floor stands\nwhere the viewer's does: both resolve it from the same mode default (`RENDER_FLOOR_PLACEMENT`).\nRender's floor carries a studio's soft grounding shadow, as deep\nas the floor is opaque: darkest where the model touches it, the key light's cast\nshadow softening and fading away from the model. The viewer re-renders its\nshadows, and re-bakes this one, only for a change that can alter them: never\nwhile the camera alone moves, and never for a hover, a selection or any other\nhighlight, which leave every part casting as it did (a highlighted part keeps\nits shadow). While a routine plays or a pose is dragged, the key's cast shadow on the\nfloor follows every frame and the contact darkening under the model at most every\n100 ms; once the model stops, both are baked for its final pose. A floor at zero\nopacity bakes nothing, and a viewer that renders in software, whose key casts no\nshadow, draws no floor shadow.\nIts finish is Matte, or Glossy: a glossier floor that also reflects the model, crisp\nwhere the model meets it and softening and fading over the model's height, as a\npolished studio floor shows a product, in either appearance. The reflection is a\nsecond draw of the scene at half the canvas's resolution, before each frame the camera\nor the model moved in (about 4 ms of render CPU a frame on a large assembly); a hover\nor selection keeps the last one and catches up within 400 ms. A Matte floor allocates\nnone of it and draws nothing extra, and switching back to Matte releases it. In\nsoftware, a glossy floor reflects nothing.\nIts double-sided surface uses its actual elevation during\ncamera depth fitting, avoiding the origin-placement near-plane gap. Its color\nand opacity are independent of Background.\nThe floor has no edge, as a product shot's seamless sweep has none: it is at its\nopacity around the model and fades to nothing a few model radii out, its shadow and\nreflection with it, so a low camera or a wide view shows no horizon line or corner.\nBehind it, Background is a studio backdrop rather than a flat fill: its color lifted\ntoward white behind the middle of the frame, shaded toward the corners (more for a dark\ncolor) and above the horizon. Its opacity carries the gradient; at zero it draws nothing.\nLight Render defaults to a pure white background and retains the slightly gray\nfloor (`#e7e7e5`); dark Render's background and floor are one charcoal (`#202226`), never\nblack. Explicit background colors still override the mode default.\nExternal STEP pose and animation passes publish current placed bounds before\ndrawing, and so does the exploded view wherever it comes to rest (a slider move,\nthe end of its ease) and at most every 100 ms while it eases in or out. Depth and\nlighting follow moving parts without changing camera framing or the floor's\nfootprint.\n\nAll modes share surface-under-cursor zoom, zero-pose framing and the fit\npadding: 1.1 across, and 1.1 down on a square or narrower viewport, easing to\n1.25 at 16:9 and wider (`interactiveFitPadding`), so a wide view leaves room\nabove and below the model. Mode changes do not refit, and neither does a\nrebuild of the open file. Motion changes geometry, not what 100% means. A\nchanged camera clears current screen-space drawings; camera state republished\nunchanged after a runtime replacement does not.\n\n## Where the controls live\n\nDisplay is a dropdown from the Display tile at the strip's end (the current mode's sphere,\nbetween Projection and Preview: `kit/shell/DisplayPopover.jsx`), present for every 3D file:\nthe offered modes (`DisplayModeOptions.js`), then the Grid switch. On a phone it is the\nMore menu's Display submenu, the same list. Preview, a view of its own over the editor's,\ndraws the model in Render whatever the file's mode (the store's `renderScene()`, as\n`kit/viewport/previewViewScene.js` draws it), and the tools view never does. Preview's own\nsettings are its settings panel (`kit/shell/PreviewSettings.jsx`: Orbit, then\n`PreviewRenderSettings.jsx`), its edits the file's Preview settings. A file's model tree and Position are\nsections of the file sidebar (see\n[settings-ui.md](./settings-ui.md#the-file-sidebar-and-the-tool-stack)); nothing in them is\na display setting.\n\nDisplay and Preview, the view's controls at the strip's end, are offered by a 3D view\nalone, as its renderer declares to the shell (`useRendererShell`'s `previewable`):\n\n| Renderer | Files | Display and Preview |\n| --- | --- | --- |\n| `step` | STEP, STP | Yes |\n| `glb` | GLB | Yes |\n| `mesh` | STL, 3MF | Yes |\n| `robot` | URDF, SRDF, SDF | Yes |\n| `dxf` | DXF | No: a 2D drawing, on a surface of its own, without the shell |\n\nA view without them has no Display or Preview control, not a disabled one, and a\nrequest for Preview leaves its normal view as it is.\n\n<!-- File: packages/ui/docs/render-types.md (Tokens: ~2054 | Category: anthropic-skill) -->\n# Render types: capabilities and the backend contract\n\nBinding for viewer work that touches more than one file format. The rule this document\nexists to enforce:\n\n> Viewer code asks what a format **can do**, never what it **is**.\n\nEvery `renderFormat === RENDER_FORMAT.X` check is a place a new format must be\nhand-added, and a place an improvement to one format fails to reach the others. That is\nnot theoretical. The Orbit button was gated off per format independently and had to be\nfixed twice; when it was finally enabled for DXF, the button still did nothing because\n**four** separate format checks stood between it and preview mode (the toolbar gate, the\nworkspace handler bail, the pane's `previewMode={dxfMode ? false : ...}`, and an effect\nthat force-exited DXF from preview). Another format grew an entire parallel export path to\nan endpoint the server does not implement.\n\n## Scope\n\nThis is the rule inside the STEP renderer (`src/renderers/step`). A DXF,\na GLB, a triangle mesh (STL, 3MF) and a robot description (URDF, SRDF, SDF) have renderers\nof their own (`src/renderers/dxf`, `src/renderers/glb`, `src/renderers/mesh`,\n`src/renderers/robot`; see [CAD renderer](cad-renderer.md#kit)): a vertical slice owns its\ntools, tabs and scene outright and consults no capability table. The `dxf`, `glb`, `stl`,\n`3mf`, `urdf`, `srdf` and `sdf` rows below remain for what is not a renderer: the file\nlist's icon and label. The headless snapshot renderer consults no table either: it draws\neach of those families with the scene builder its renderer uses\n([one scene builder per family](cad-renderer.md#one-scene-builder-per-family-the-viewer-and-the-snapshot-cli)).\n\nThe DXF slice does not even have a scene: it is a canvas painted from\n`GET /__cad/drawing`, so none of the viewport capabilities below describes it — and its\nrow says so, with `assetKind: drawing` and no viewport tools (its own are its renderer's). `cadgen dxf snapshot` paints the same\npayload with the same code (`@text-to-cad/core/lib/drawing2d`), so the CLI cannot produce a\npicture the pane could not.\n\n## The capability registry\n\n`packages/core/src/lib/renderCapabilities.js` — one frozen table, keyed by render\nformat. Pure data: no behaviour, no imports beyond the format enum.\n\n| Capability | Meaning |\n|---|---|\n| `assetKind` | Which asset a format LOADS: `mesh`, `drawing`, `robot`. |\n| `iconKind` | The file-list glyph. |\n| `tools` | `select`, `pan`, `draw`, `orbit`, `screenshot`. Orbit and screenshot are true for every format WITH a viewport — they act on the viewport, not the geometry. `dxf` claims none of them: it has no viewport, and its tools (Select, Annotate, Measure) are its renderer's own, over a 2D canvas. |\n| `parts` | Per-part selection, hiding, isolate, assembly tree. |\n| `topology` | Face/edge/vertex references. Implies `parts`. |\n| `params` | `sidecar` (the model's `@step(pose=...)` block), or `null`. |\n| `animations` | Has animation clips, so transport controls apply. |\n| `artifactManaged` | Builds a package before it can render. A format listed here that the backend cannot produce a package for blocks forever, so a format the viewer renders from its own file belongs out. |\n\nWhat a 3D file's tools and view offer — Measure, Clip, Explode, the display modes — is not\nin this table: each renderer offers its own tools ([CAD renderer](cad-renderer.md#tools-on-a-scene-of-triangles)),\nand `viewFeaturesForKind` (`@text-to-cad/core/common/viewSettings.js`) is the one rule of\nwhat a file's view offers, for the viewer and a snapshot alike.\n\n### Rules\n\n- Add a capability when the **second** format needs it, never speculatively.\n- An unknown format resolves to the conservative default row (everything optional off).\n  Deliberately *not* `normalizeRenderFormat`, which resolves unknowns to STEP and would\n  hand an unrecognised entry STEP's full capability set.\n- Capabilities decide **which** panels and tools mount. Format-specific *content* — STEP's\n  tree — stays format-specific.\n\n## The content signal\n\n`selectedViewportContent` in the STEP renderer is the single answer to \"is there anything on\nscreen?\", its loaded mesh data. Toolbar gates, the CTA, preview mode, the viewport\ncontext menu and alert blocking all read it, rather than each one re-deriving the answer\nper format.\n\n## The render-backend contract\n\nThe kit's viewport (`renderers/kit/shell/ShellViewport.jsx`) is the shell and owns the camera,\n`OrbitControls`, the scene stage, frame presentation, the Draw overlay, screenshots and the\nimperative viewer API. A renderer's **scene** (the contract `renderers/kit/scene.js` re-exports from core; STEP's is\n`renderers/step/scene/stepScene.js`) owns geometry only:\n\n1. **Consume content**: the document its renderer loaded (a STEP's mesh data, a GLB's native\n   scene, a mesh file's objects, a robot's parts). A GLB's, a mesh's and a robot's scene is\n   built in `@text-to-cad/core`, where the snapshot CLI builds the same one.\n2. **Publish bounds** so the shared fit and its zoom baseline work. The mesh\n   path does this via `applyRuntimeModelBounds` after composing; a backend with no mesh\n   calls back with its own bounds instead.\n3. **Optionally install loop-tuning hooks** on the runtime. All are inert unless set, so\n   the mesh path is unaffected:\n   - `renderOnDemandOnly` — do not hold the render loop open for a whole gesture.\n   - `idleQualityDelayMs` — raise the idle-restore delay.\n   - `onIdleQualityRestore` — restore quality before the pixel ratio, so the expensive\n     frame and the drawing-buffer reallocation do not land on the same vsync.\n   - `resolveExtraPixelRatioCap` — cap resolution below the shared caps.\n\nA backend never reaches into the camera, controls or stage. If it needs something from\nthem, that is a shell feature and belongs in the shell where every format gets it.\n\n### Adding a format\n\nDeclare a registry row, implement a backend, add a fixture to the sweep. Do not touch the\nshell. If you find yourself adding a format check to `FloatingToolBar` or to anything under\n`renderers/step/scene/`, the capability you need is missing from the table.\n\n## Enforcement\n\nA ratcheting policy test in the repo where this app is developed counts identity\nchecks in non-test client code: the number may only go down. It also asserts a\ngrowing set of files at **zero** — the toolbar, every module of the STEP scene\n(`renderers/step/scene/`), the alert\nbuilder, the file-list icon and status, and the home screen — since those are the surfaces\nevery format flows through. Lower the budgets in the same commit that removes checks.\n\nWhat is left is deliberate. `useCadAssets` is allowlisted: choosing and running a loader\nper format is its whole job, and the `assetKind` field names *which* loader without\npretending the implementations are the same. `stepArtifactStatus.js` keeps its checks\nbecause STEP package error codes, the `stale` flag and the renderable-GLB fallback are\nSTEP vocabulary. STEP is now the only artifact-managed format: a DXF is rendered from\nthe file that is on disk — the backend flattens those bytes to 2D primitives per request\nand never owns a package for one (`owns_dxf_path` always answers False).\n\n## Standing gate\n\nThe `web` CI job's `scripts/test/test-viewer-browser.sh` opens one fixture per load path\n(STEP, STL, DXF, URDF) through the real backend and the bundled viewer and asserts each\ndraws something with no page errors: a blank-but-error-free viewport is the signature\nfailure here (a shader that fails to compile, a gate that hides the geometry). It runs on\nevery change to shared viewer code; there is no separate manual sweep.\n\n## Known non-uniformities\n\nRecorded so they are not mistaken for bugs, and so the next person knows the cost:\n\n\n## Scene recipe conformance\n\nInspect and Render resolve through `@text-to-cad/core/common/sceneSettings.js`.\nInspect's fixed light/dark workbench basis follows app appearance; Render uses\nits independent studio configuration. Legacy CAD theme preferences are not an\ninput. The internal `themeSettings` recipe remains a renderer implementation\ndetail, normalized through the shared core schema so every backend receives the\nsame lighting, materials and stage settings.\n\nThe browser qualification in `scripts/test/test-viewer-browser.sh` opens every\nload path through the real backend and bundle and checks mode framing, that a saved\nrevision keeps the camera and that Zoom to fit frames the revision; picking and the\ndisplay presets are the `packages/ui` browser specs'. Both use\nisolated generated fixtures rather than a sweep of saved theme presets.\n\n<!-- File: packages/ui/docs/renderers.md (Tokens: ~370 | Category: anthropic-skill) -->\n# File renderers\n\nThe viewer renderers, `cad` (STEP), `dxf`, `glb`, `mesh` (STL, 3MF) and `robot` (URDF, SRDF, SDF), and\nthe kit and shell they are built on are in [CAD renderer](cad-renderer.md). They are\nthis package's renderers because every host registers them.\n\nA renderer only one host registers belongs to that host, with its own\ndependencies and tests. It uses only this package's public exports, and they are\nwhat a host renderer may rely on:\n\n- `defineFileRenderer` and the registration contract from `@text-to-cad/ui/file-viewer`\n  ([FileViewer](file-viewer.md)), including `fallback: true` for the one\n  renderer that takes files nothing else matches.\n- The file it is handed (`FileMetadata`) and its `FileSource`, which names and\n  describes files and reads nothing else: a renderer reads a file's content through\n  what its host injects into it (the CAD renderers take a `CadWorkspaceService`).\n  Every resource a renderer acquires carries a release; it returns that as the\n  prepared document's `dispose`, and FileViewer releases it when the request is\n  cancelled, the file changes, or the view unmounts.\n- The host ports in `@text-to-cad/ui/host`: `useViewerHost`, and `PromptContextAction`\n  for prompt delivery.\n- Shared chrome from `@text-to-cad/ui/navigation` (`EmptyState`),\n  `@text-to-cad/ui/primitives/*` and `@text-to-cad/ui/utils`, styled by this\n  package's `styles.css` and the host's own Tailwind build.\n\n<!-- File: packages/ui/docs/settings-ui.md (Tokens: ~12642 | Category: anthropic-skill) -->\n# Viewer interaction and settings design system\n\nThis is the binding contract for the viewer's tools, its file sidebar and its\nsettings, in both apps. Build from the shared pieces — the\n[toolbar button](../src/primitives/toolbar-button.jsx),\n[FloatingToolBar](../src/renderers/kit/tools/FloatingToolBar.js),\n[SubToolbar](../src/renderers/kit/tools/SubToolbar.jsx),\n[ToolPopover](../src/renderers/kit/tools/ToolPopover.jsx),\n[FileSidebar](../src/renderers/kit/tools/FileSidebar.jsx),\n[ToolStack](../src/renderers/kit/tools/ToolStack.jsx),\n[ToolPanel](../src/renderers/kit/tools/ToolPanel.jsx),\n[the floating surface](../src/lib/floatingSurface.js) and the\n[FileSheet primitives](../src/renderers/kit/inspector/FileSheet.js) — and extend\nthem rather than recreating their layout in a renderer. Environmental effects\nbelong to apps through the [host contract](viewer-host.md).\n\n## Ownership and layout\n\nFileViewer owns the navbar — the logo's app menu ([Settings](#settings)) included, built from\nthe host's `links` and the `appSettings` `CadViewer` hands it — and the file explorer (a\npopover under the file's name). RendererShell owns the scene's chrome: the tool strip and\nthe view's controls at its end (Display, Preview), the sub-toolbar of the tool in hand under\nit, the file sidebar, Quick Edit, the view cube and Preview. Renderers supply\ntheir tools, their tools' rows, their sidebar sections and their document state; the shell never\ninspects a format's parts, joints or topology. No pick or tool opens or closes the explorer.\n\n| The host (web, desktop) supplies | The shared UI decides |\n| --- | --- |\n| Where the tab record lives (`TabRecordStorage`: the web's sessionStorage, the CAD app's memory) | The record: its settings (the file sidebar's and tool stack's layout, the appearance, the home's layout) and the view of the file on screen (its camera, Display settings, its playback settings, and renderer slices), what is written when, and what is never stored (`@text-to-cad/ui/tab-store`, `kit/shell/fileView.js`) |\n| `navigation.home`, `links` (its version, GitHub, Discord, where a new issue opens, how a link opens), `appSettings` (the person's own settings), `colorScheme` (the person's theme and how it changes) | The navbar's order and look, over a file and over the home, the app menu (one menu in the viewer and on the home) and what a new issue says |\n| `host.files`, `fileActions`, `navigation`, `clipboard`, `promptContext`, `promptFiles` | When a copy, capture or open happens and what it carries |\n| `host.environment`: color scheme, keyboard `platform`, `reducedMotion` | How the chrome honours them |\n| `onError`, for errors the viewer hands up | Preview, tooltips, keyboard scope, the tool strip and sidebar, the camera |\n\nHosts pass no Preview, chrome-visibility or notification props; there are\nnone.\n\n- **Navbar.** One row, the same in every app, over every file and over the home. Left: the text-to-cad \"C\"\n  logo, named Menu, the size of the row's icon buttons, with no chevron, which opens the\n  **app menu** (see [Settings](#settings)) — a host with no home has it too — then, where the\n  host keeps the view's own history (`history`: the CAD app's views, which no browser holds),\n  **Back** and **Forward**, two arrows walking it as a browser's do, from file to file and to\n  and from the home (a step like a file), each there always and disabled where it has\n  nowhere to go; then the open file's name and its ⋯. Each of these that\n  is a control (`NAV_ITEM_CLASS`) is transparent at rest, takes the same accent background\n  while the pointer is on it and while its menu is open, has a pointer cursor and no tooltip:\n  it says what it is. The name\n  is that control where it opens the explorer (below), where the host's files can be\n  browsed; where they cannot it is plain text. The ⋯\n  is the file menu: Copy path, and Reveal in Finder / Show in Explorer / Show in file\n  manager, each only where the host can do it, and no ⋯ where it can do none. The name has\n  no right-click menu and there are no crumbs. The left's controls sit edge to edge, spaced\n  by their own padding alone: the logo's 24px button, Back, Forward and the ⋯ in 20px ones,\n  and the name 4px in from its edges, so about 7px separate one glyph from the next. Right, only what a host or a file adds: first, the\n  update — a blue download button, there only when the host\n  found a newer release, whose menu says the step to it, how this host updates and what\n  is new. Then, while a person has put an alert card away: the card's own icon (a circle\n  with an exclamation mark, red for an error and amber for a warning, named and hinted by\n  the alert's title), which brings the card back and goes with it — the renderer's one\n  navbar action. Then, where the host shows the view small (inline in a conversation,\n  where the view is the whole viewer), its **Full size**, corner brackets. A CAD file publishes no navbar action but that\n  icon. The person's own settings, the theme among them, are never here: they are the app\n  menu's.\n\n  *Design note: the strip is for tools; the view's controls are the navbar's.* What a host or\n  the file adds (the update, an alert's icon, Full size) is the navbar's right end, and last\n  after them the view's own controls, **Display** and **Preview** (`navbarSlot`): they act on\n  the whole view, whatever tool is in hand, so they are no tools. In Preview the same place\n  holds its own controls, **Preview settings** and **Exit preview**. A host with no navbar has\n  them over the view instead: Display and Preview ending the strip after a thin rule, the\n  Preview's pair in the view's top-right corner. A new per-file button that is no tool goes\n  with them.\n\n  A host's home has the same navbar, with only the logo and its app menu (without Back to\n  files) and, where the host keeps the view's history, **Back** and **Forward** at the left\n  and, at the right end, the host's update, when there is one, and **Full size**, where the\n  host shows the view small — the same buttons, sizes and places as over a file. Under it,\n  the page: the TEXTTOCAD wordmark and byline, then GitHub (its mark, a link to the project:\n  it is open source), Discord and X, as icon links (GitHub and Discord are the app menu's\n  too, X its footer's \"Made by\"; no light/dark button: Theme is the menu's), then Recent Files with its search, grid/list switch\n  and Open where the host has a file chooser: the models opened before, pinned first, as cards\n  or rows.\n  The update shows only for a release\n  later than the version the page names, whatever the host says. A file that will not open\n  shows a page under the navbar: \"File does not exist\" and the file's path, or \"Could not\n  open that file\" and the reason, each with **Go home** where the host has a home. A view\n  drawn as a picture (`compact`, the home's thumbnails) has no navbar either, and draws the\n  model alone: no tools, view actions, cube or Quick Edit.\n- **File explorer** is a popover under the file's name (`FolderExplorer.jsx`), aligned to\n  the name's left edge, 18rem wide and never resized, as tall as its rows up to 28rem or\n  the room below it: past that its list scrolls. It floats over the view and the file\n  sidebar, so opening it resizes and moves nothing. It starts in the open file's folder and\n  lists one folder at a time — its subfolders, then its CAD files — in compact rows: one 24px\n  line of 12px text each, its kind a small light glyph in one column 5px in from the row's\n  left end and 4px before the name (a folder its disclosure chevron, with no folder icon; a\n  file its type's icon), as the file sidebar's trees draw theirs. A press on a subfolder opens it\n  inline, under itself, its rows 10px further in and hung from a faint line under its\n  chevron, read once when it first opens; another press closes it, and Right and Left do the\n  same from the keyboard. A double-click opens it in the explorer's place, as a crumb does —\n  the breadcrumb then names it. The breadcrumb is\n  as many of the last three folders as fit whole: every crumb is its folder's whole name, the\n  farthest giving way first to the \"…\" (an ellipsis icon), which lists every folder above the crumbs to jump to,\n  and only the folder it is in, left alone beside the \"…\", is cut (its name a hint while it\n  is). The folder it is in reads in the foreground color, the others muted; there is no up\n  arrow, and the open file is highlighted, the list opening scrolled to centre its row, once (a\n  long folder's too). Opened, its filter has the keyboard, so typing\n  finds a file at once; on a phone the explorer itself holds focus, keeping the keyboard\n  down. \"Filter files...\" under the breadcrumb searches\n  every file nested under the folder (the server's bounded search, debounced 150ms, each\n  keystroke cancelling the search before it); a search that stopped early says \"Stopped\n  early. Keep typing to narrow the search.\" Up and Down move between the filter and the\n  rows on screen, the rows of a folder open inline among them. A pick opens the file and\n  closes it, as do Escape and a press anywhere outside it.\n- **Tool strip** at the top centre, 8px down, never wider than the view less 8px at each\n  side (it wraps before it would be): the tools, left to right, in **two sections** parted by one\n  thin rule (a tool's `group`: the tools that pick, Select, Pencil, Shapes and Measure; then the\n  model's effects, Explode and Clip), and, where there is no navbar, the view's controls,\n  **Display** and **Preview**, directly after the last tool, in the same second section with no rule\n  between (a file with only tools that pick has its view's controls after a rule, as their own second\n  section). Where a navbar holds the view's controls the strip is the tools alone: below. 24px tiles with 14px icons,\n  an even 4px apart, a tool's chevron touching its button, in a 34px row (`FloatingToolBar`,\n  `data-cad-toolbar`). A file with no tools has no strip. On a phone\n  ([mobile](#the-file-sidebar-and-the-tool-stack)) the tiles are 36px with 18px icons, a chevron\n  20px wide, in one row that never wraps. Centre it on the viewport whenever it fits;\n  clamp its position to keep 8px from the edges and from the file sidebar's tile at its left.\n  Its available width still uses all the room after that tile. What does not fit goes, from the\n  strip's end (the view's controls where the strip has them, then the tools from the last), into **More tools** (an ellipsis, last), a tool a row ticked while it is\n  in hand and Display opening its popover from More (`touchStripFit`).\n- **Sub-toolbar** under the strip, 8px below it, centred: the tool in hand's one row of\n  controls (`SubToolbar`, `data-cad-sub-toolbar`), there for as long as the tool is up (see\n  [Tools and lifecycle](#tools-and-lifecycle)). Select and Measure have none. On a phone its\n  buttons are the strip's 36px tiles and its sliders as tall, the slider rows 20rem wide.\n- **Tool stack** under the sub-toolbar, in the same centred column: a renderer's own\n  `toolPanels`, where it hands any (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)). The column\n  stops 8px above the cube's line (`VIEWPORT_STACK_BOTTOM`), so it never runs past the\n  view.\n- **File sidebar** at the top-left, 8px in: a toolbar of tiles, one per section of what there\n  is to read of the file — a STEP assembly's Model tree, a robot's Links, Position, an SDF's\n  metadata, the routines' Animation — whatever tool is in hand; a tile opens its section in a\n  panel under the tiles, one at a time, down to 8px off the view's bottom at most. On a phone it\n  is one tile, opening one panel with the sections as tabs (see\n  [The file sidebar and the tool stack](#the-file-sidebar-and-the-tool-stack)).\n- **Quick Edit** at top-right, 8px in, a STEP's or a robot's: the box of the note a\n  person writes to their agent, there only while something is picked, drawn or measured\n  (see [Quick Edit](#quick-edit)). The rest of the right side of the view is clear.\n- **View cube** at bottom-right, 2px from the right and 8px off the bottom so the axes\n  drawn in its lower corner never touch the edge (`VIEWPORT_CUBE_BOTTOM`), in a 6rem area:\n  enlarged face/edge/corner hit areas and neutral hover and XYZ guides. A host whose own\n  control floats over that corner lifts it clear with `--cad-viewport-corner-bottom`, the\n  room that control takes from the bottom edge. A host can instead anchor it at bottom-left\n  with `--cad-viewport-corner-left: 2px` and `--cad-viewport-corner-right: auto`.\n  Codex's tab presentation uses this for all its views to avoid the floating composer.\n  Preview omits it, and so does a phone.\n- **Display and Preview** end the navbar, for a 3D file (`data-view-controls`, the box\n  FileViewer hands the renderer as `navbarSlot`): **Display** (the active mode's sphere: see\n  [Display settings](#display-settings-and-section-primitives)) and **Preview** (a play\n  triangle, the way into Preview), the navbar's icon buttons, hinted below them. They are no\n  tools: pressing one leaves the tool in hand as it is. Display's dropdown opens down from its\n  button, aligned to its end, never taller than the viewer below it. A view that is not 3D has\n  none of them, and neither does a view drawn as a picture or one that is loading or failed to\n  load; in Preview the same place holds its own controls (see\n  [Preview](#camera-animation-and-preview)). With no navbar (a host showing the view\n  alone) they end the tool strip after its rule, and Preview's stand in the view's top-right\n  corner.\n- **Nothing sits at the bottom centre**, in the tools view or Preview: a file's routines\n  play from the file sidebar's Animation section (see [Preview](#camera-animation-and-preview)).\n- **Model update status** sits under the strip and its sub-toolbar, centred, and where\n  there is no strip (Preview, a compact view, a load) at the top centre, in the strip's\n  34px row, in every host. It is renderer chrome: the navbar carries no status. On mobile\n  it is a tappable progress icon whose popover names what is loading.\n\n## Tools and lifecycle\n\n| File | Tool strip, left to right |\n| --- | --- |\n| STEP | Select, Pencil, Shapes, Measure; Explode (two or more parts), Clip, Display and Preview |\n| URDF / SRDF / SDF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| GLB | Select, Pencil, Shapes, Measure; Explode (several rigid nodes that draw), Clip, Display and Preview |\n| 3MF | Select, Pencil, Shapes, Measure; Clip, Display and Preview |\n| STL | Select (in hand, the view's navigation: an STL picks nothing), Pencil, Shapes; Clip, Display and Preview |\n| DXF | Select (in hand, and the view's hand: a drag pans, a tap picks an entity), Pencil, Shapes, Measure; no view controls: a drawing is 2D |\n\nThere is no activity dot. A thin rule parts the tools from the view's controls, which\nare no tools (see [Ownership and layout](#ownership-and-layout)). Position is no tool:\nits knobs are Select's, while Select's filter picks Position, and its controls are a\nsection of the [file sidebar](#the-file-sidebar-and-the-tool-stack), and so is Animation, where\nthe file's routines play (see [Preview](#camera-animation-and-preview)). A file with no tools\nand no 3D view has no strip at all.\n\n**A tool the file cannot offer is not on the strip**: never shown disabled.\nRenderers leave it out of the `tools` they hand over; `FloatingToolBar` draws\nwhat it is given. Until a file has loaded, what it offers is not known: a tool\nthat depends on it (Explode) is shown idle meanwhile, a robot's filter offers\nPosition meanwhile, and the whole strip is disabled while loading. Unavailable and\nidle are different states.\n\nOne tool owns pointer input, and it alone is lit on the strip. An effect a person set —\nan explosion, a cut, a pose — stays on the model once its tool\nis put down, with nothing on screen for it but the model. **A press on the tool in hand\ndoes nothing**: a tool is put down by taking up another (Select, most often), or by Escape, which\ntakes Select up. A press on\nExplode or Clip with its effect set takes it up again, as it is; unset, it starts neutral.\n\n| Tool | Pressing it | Its sub-toolbar | Leaving it |\n| --- | --- | --- | --- |\n| Select | The default tool of every 3D file but an STL; picks what its filter ticks, or a GLB's nodes, a 3MF's objects (Shift adds; Delete deletes annotations and measurements); what is picked is Quick Edit's chips | None: its filter is the chevron after its button | The selection stays, lit and in Quick Edit, under any tool |\n| Pencil / Shapes | Draws on the view: Pencil with the pen, the highlighter or the eraser, Shapes with the rectangle, line, arrow, ellipse or text; each button is the tool last chosen in it | One row for both: the tools of the one that is up, then Color, Stroke width, Placement and Clear annotations (Undo and Redo are ⌘Z, ⇧⌘Z) | The annotations stay in the model, their chip in Quick Edit; each tool keeps what it draws with, the strip showing its icon |\n| Measure | Arms picking two entities and reading what the pair makes (a STEP's exact vertices, edges and faces, what its filter ticks; on a mesh, two points on its surface, approximately, in its file's unit; a drawing's ends, centres and lines, in the unit it declares, else \"units\"); each result a chip in its colour | Its measurements, as chips, then Clear measurements; none while nothing is measured | Unfinished picks are cancelled; completed measurements stay, drawn on the model, their row back with the tool |\n| Explode / Clip | The effect as it is set, or neutral; an edit applies it (Explode's, as a motion: see below) | The slider, its amount beside it, Reset (Clip's: its axis first, whose dropdown holds its settings) | The effect stays on the model |\n\n**One motion at a time.** Every tool's state stands beside every other's — the selection,\nmeasurements, the annotations, Clip — except the two that move the parts: **Explode** and\nthe **Position** pose. They replace each other: taking up Explode puts the pose back at its\ndefault, and using Position (a knob dragged, a value set or a named pose chosen) takes\nExplode off, with nothing set aside to come back. While the model is exploded there are no\nknobs. Put down, Explode keeps its explosion under any other tool until Position is used. A\nroutine played from the Animation section is the third motion: playing it puts Explode and the\npose back at rest, and using either puts the routine back at rest; paused, its frame stays on the\nmodel as a pose does. Preview's routine is its own and moves nothing of the tools view's.\nAnother file has the motions its tools have, under the same rule, decided by value\n(`kit/tools/model-effects/motionExclusion.js`): a GLB's Explode, routine and Clip; a robot's\nPosition and Clip.\n\nA tree is the file sidebar's, on screen under every tool; a pick in it takes Select up\nbefore it acts.\n\n**Removing an effect.** Explode's and Clip's sub-toolbars end in\nReset (\"Reset explode\", \"Reset clip\"): always there, disabled while there\nis nothing to take away, it removes the effect — the amount back at zero, nothing else (Clip's\naxis and its settings stay as set) — and leaves the tool up. Position's way back is its Pose row's Default. A\nmeasurement's chip X deletes it, and Measure's Clear measurements all of them; the Annotation chip's X clears the annotations.\n\n**Select and Measure are the tools with a menu on the strip** (Measure's on a STEP only), each its selection filter: the\nchevron right after the button (`ToolSettingsMenu`'s \"chevron\" trigger, `kit/tools/ToolModeMenu.jsx`),\nas tall as the tile and touching it. The button and its chevron are **one control**\n(`FloatingToolBar.js`): one rounded fill lights the pair on hover, while the tool is in hand and\nwhile its menu is open, and the chevron's half only brightens over it, on its own hover and\nwhile its menu is open, with no line between the halves. A change\nto a filter takes its tool up. A press on any other tool is its only action. Whatever a tool can\nbe set to while it is up is its sub-toolbar. A dropdown is ordinary — under its own button,\nfree to overlap the view — and closes with no exit animation, so a quick second tap (touch\nincluded) always reaches its trigger. Choosing a value closes it; ticking an option leaves\nit open, as do Clip's axes and the app menu's Theme. Menu checks sit on the right. The other dropdowns over the viewport are Display,\nthe sub-toolbars' (Clip's axis and settings, the drawing tools' colour, stroke width and placement), its\ncontext menu, and the Animation section's routine and Playback settings (`PlaybackMenu.jsx`, on\n`ToolPopover`). Preview's settings are a panel, not a dropdown (see [Preview](#camera-animation-and-preview)).\n\n**Select's menu** (`kit/tools/select/SelectFilterMenu.jsx`) is a CAD tool's selection\npriority (Fusion's Select > Face Priority, Blender's vertex, edge and face modes), not a\nchecklist: the chevron's dropdown, headed \"Pick\", lists ONE choice of what a click on the model\ntakes, each with its glyph — **All** first, the default, then each kind alone. Choosing\none puts the menu away and takes Select up. A STEP's (`SelectionModes.jsx`'s `stepSelectModes`):\n**All** (the pointer: a face or an edge where a part's are loaded, the part elsewhere,\nPosition's knobs, the annotations and the measurements), **Parts** (a whole cube; an assembly's\nonly — in a part file a press that reaches no face or edge picks the whole part), **Faces** (a\nfaint cube, its top face filled), **Edges** (a faint cube, one edge heavy) and **Position**\n(where the file has joints to move: its knobs alone, the model picking nothing). A robot's:\n**All**, **Links** and **Position** (where a joint can be driven; a robot with nothing to\ndrive has no menu). The annotations and measurements are no choice of their own: they are drawn\nover the model, so a click on one is never taken for the face under it, and a kind alone (Faces)\npicks that kind only. All is chosen when a file opens; the choice holds while the file is\nopen, across updates of it, and another file starts on All. The chevron's name says what\nis chosen (\"Selection filter: everything\", \"Selection filter: Faces\"), and Select's button wears\nit as a badge on the pointer (`modeGlyphs.jsx`'s `ToolModeIcon`; none for All), so what a\nclick takes is on the strip, not only in the menu. After a rule, a STEP's menu ends in how a face\nor edge pick grows, each with its glyph and independent of each other: **Group edges** (the\nconnected edges with a picked edge), shown while edges are picked, and **Group faces** (the\nsmoothly joined faces with a picked face), shown while faces are; a hidden one keeps its choice\nfor when it shows again.\n\nThe geometry chosen is picked as one mode (`step/workbench/selectionFilter.js`'s\n`selectGeometryMode`): All, a press takes the face or edge it lands on where the part's\ntopology can be picked, and the part elsewhere; Parts, the part; Faces or Edges, that topology\nalone. The mode sets the model tree's shape: under All, or Position (no geometry at all),\nit is the person's own (put back as it\nwas when they left it, with the owners of what is still selected kept open); Parts alone\nopens every assembly and shuts every part; Faces or Edges open every assembly. A\npart is a leaf in every shape: nothing opens under it, and a face or edge picked in the\nviewport is revealed at its part's row. Outside the person's own shape the disclosure is\nlocked (chevrons shown, not pressable) and Expand/Collapse leave the row menus. Under Faces\nor Edges a part's topology is asked for as its row comes on screen in the tree, never for a\nwhole large assembly at once; a viewport press on a part not yet loaded loads that part and\npicks, and the model tree's heading says \"Loading…\" meanwhile. In a large assembly (more than 300\nrows of assemblies and parts, `LARGE_TREE_ROWS`) a part's row coming on screen asks for\nnothing: the pointer resting on the part in the viewport (150ms) or pressing it asks.\n\n**Sub-toolbars** (`kit/tools/SubToolbar.jsx`): the tool in hand's controls, one row hung\nunder the strip, centred, 8px below it, on the strip's surface, its buttons the strip's\n24px tiles, its groups — what the tool does, then how it looks and what clears it — parted by a\nthin rule. It stays up for as long as its tool does, and has no heading: the tool's lit\nbutton above names it. Explode's and Clip's are one shape — what leads\nthe slider, the slider, its value right beside it and only as wide as\nits text, the tool's actions (neither has any), then Reset, last, at the same spot in both,\nwith no rules — and one width (`SLIDER_SUB_TOOLBAR_CLASS`, 202px; a phone's 320px). The slider\ntakes the rest of the row. A tool's options never sit in the row as checkboxes, labels or\nbuttons of their own, which would squeeze the slider: they are the dropdown of what leads it\n(Clip's axis).\n\n**Pencil and Shapes.** Drawing is two plain strip tools, a button each, with no chevron and no menu:\n**Pencil**, drawing with the **Pen** (the freehand ink, tapering with pressure or\nspeed), the **Highlighter** or the **Eraser**; and **Shapes**, with the **Rectangle** (what it opens on), **Line**, **Arrow**,\n**Ellipse** or **Text** (a mark put down in place). The tool's button shows the icon of what it draws with\nnow, and a press takes up the one last chosen. Pencil and Shapes share the annotation and its history, whichever is up, but not the style: the colour, stroke width\nand placement are kept **per drawing kind** — the Pen, the Highlighter (which opens in neon yellow) and Shapes (all five\nshapes share one) — and the row shows and sets the tool in hand's own — and **one sub-toolbar**,\n\"Annotation controls\" (`annotation/toolbar.jsx`'s `AnnotationToolbar`), a single row, never a\nsecond, from the left: the **tools of the one that is up**, a button each, the one in hand pressed (with Pencil up, Pen,\nHighlighter, Eraser; with Shapes up, Rectangle, Line, Arrow, Ellipse, Text: choosing one is how the strip tool changes what it\ndraws with); a thin rule; then what is drawn next, each a dropdown whose button shows its value, no\nchevron: **Color** (no heading, a grid of swatches, unnamed but for assistive technology: the neons and\none neutral that reads against the viewer, white in dark mode and near-black in light, then\nthe colour wheel, which opens the system's colour picker for any other colour, kept once the\npicker closes), **Stroke width** (no heading, four weights, 1, 2, 4 and 6px, the pen drawn to look the\nsame weight; on the button and as each row, a straight line drawn at that weight, unnamed but for assistive\ntechnology, the current one checked) and **Placement** (laid out as the strip's menus are: headed with its name, a\nrow per choice, the check on the right: **On surface**, the default, its glyph a sphere: each point of the next mark on the visible\nsurface under it; **On plane**, its glyph a sheet: the whole mark on the plane of the surface it\nstarts on; a flat view, a DXF, has none); then **Clear annotations** (the trash), disabled while there is\nnothing to clear. Under the **Eraser**, which draws nothing, Color, Stroke width and Placement are disabled (greyed out, still\non the row so it does not jump). Undo and Redo have no buttons: they are ⌘Z and ⇧⌘Z (Ctrl+Z, Ctrl+Y). The row is the same row for both tools at every width; on a phone its\nbuttons are 36px tiles and, where the row is wider than the view, it wraps rather than scrolls (there is no second menu on\nthe strip for it). The **Highlighter** draws a freehand stroke like the pen's, but broad and translucent: five times\nthe chosen stroke width, 35% opaque, round at its ends, with no pressure taper; it is an ordinary stroke, picked,\nerased and recoloured as any (its Stroke width in the selection's bar is the same setting). The tools have no Select or Pan of their own (Select picks\nannotations, a right drag or Space orbits). Never a restyle of ink already down: the\nannotations Select picks have their own bar, a small floating toolbar on the tool strip's\nsurface just above them — Color and Stroke width dropdowns, Fill for a closed\nshape, a rule, Delete. An update of the model forgets the annotations too, their history with\nthem (Undo has nothing to bring back), and leaves the tool in hand, with its tool, colour,\nweight and placement. The pen and the shapes share one default stroke\nwidth. The annotations have no panel: while there are any they are Quick Edit's\n**Annotation** chip (its X clears them), and while Pencil or Shapes is up the copy shortcut writes\nthe view with its annotations to the clipboard as a PNG. A first stroke opens\n[Quick Edit](#quick-edit), whose header says **annotation** while there is ink. The drawing tools\ndisable the cube without hiding it.\n\n**Measure.** Its button is the ruler, badged as Select's is with what it\npicks alone. On a STEP, its menu is Select's own (`kit/tools/measure/MeasureFilterMenu.jsx`):\n**All** (when a file opens), **Vertices**, **Edges** or **Faces**, one chosen. A press takes an\nENTITY, whole, as a CAD measure tool does: an edge's end within 10px of the pointer is its\nvertex, else the edge, else the face (`measureFilter.js`). What lights under the resting pointer\nis what a press takes, with what it is worth on its own beside it (a vertex's X Y Z, an edge's\nlength or ⌀, a face's area). With one kind chosen, only that kind is taken: an edge's end is still\nits vertex under Vertices, and anywhere else a press takes nothing. Shift held, a press takes the\npoint on the model as it is. In an assembly, a press on a part whose faces and edges are not loaded asks\nfor them and takes the pick there once they arrive.\n\nThe reading follows from the pair (`@text-to-cad/core/lib/viewer/measureEntities.js`), not from\nwhere on each the pointer happened to be:\n\n- two vertices: the distance, with ΔX ΔY ΔZ;\n- parallel faces, parallel edges, a vertex to a face or an edge: the perpendicular distance, \"⟂\";\n- a hole (a circular edge or a cylinder) to anything: from its centre or axis, so two holes\n  read \"C-C\" and a hole to a face its distance from that face;\n- faces or edges at an angle: the angle, \"∠\", the acute one;\n- anything else: the shortest distance between the two.\n\nA pair with no gap (an edge in its face, two flats at one height) reads as the distance between\nthe two points picked. A press on the entity already held lets go of it, as does a press on\nnothing. On a mesh there are no vertices, edges or faces to take: a press takes the point on its\nsurface under the pointer, and the menu goes. A drawing's Measure has no filter.\n\nIts sub-toolbar is its measurements (`kit/tools/measure/measurementChips.jsx`'s `MeasurementsBar`),\nthere while Measure is the tool and something is measured, and no row at all while nothing is:\neach completed measurement a chip, Quick Edit's own (`ReferenceChips.jsx`, a few then \"+N more\"),\nthe ruler in the measurement's colour (the colour of its line on the model) and its reading; its\nX deletes it, resting on it lights its line and labels it, and its card, opening under it, is the\nfull reading (what the pair makes, the angle or the shortest distance with it, the delta along\neach axis between two points, what each end bound to); then a rule and **Clear measurements**.\nMeasurements are the person's alone: none of them goes to the agent, in Quick Edit or its\nsnapshots, and measuring never opens Quick Edit. They stay, drawn on the model, under every other\ntool; a press on Measure while it is up does nothing.\n\n**Explode and Clip** have no enabled checkbox, and their sub-toolbars are drawn alike.\nExplode's: its slider, the amount (a percentage), **Reset explode**. Clip's: its axis — a\nborderless button reading the axis in hand, \"X ⌄\", lit while its menu is open — its slider, the\namount, **Reset clip**. The axis's dropdown holds all of Clip's settings, on Display's surface\nand in its type, as narrow as its content, with no heading and no glyphs: first X, Y and Z, a\ncompact group of the app menu's Theme buttons (24px squares, its radius, no track) in a row as\ntall as an item (a phone's 40px, as Theme's row), the axis in hand in the accent a highlighted\nitem wears; a rule; then two of Display's `DropdownMenuCheckboxItem`s, plain text with the check\nat the right: **Part colors** (ticked by default: each section cap in its\npart's normal colour, without hover or selection tints; unticked, every cap one amber) and\n**Reverse** (unticked by default: which side of the plane is kept). Caps stay opaque, a\ntransparent part's too. A press on any of them leaves the menu open, and none of them applies a\ncut; on a cut, a new axis or Reverse turns it keeping its share. They are settings of the\nfile's view (`clip.axis`, `clip.partColors`, `clip.invert`, stored and restored with its\ndisplay), kept across tools, by Reset clip and by taking up an unset Clip. Explode opens at 0%, Clip at no\ncut; an edit applies the effect, and the slider back at zero removes it. An axis alone is not\nan effect. The view settings own both effects, so a mode reset preserves them and a restored\neffect reappears on the model. Clip's slider, left to right, cuts deeper through the original\nbounding box; pose, animation and Explode never redefine the range. It shows the share cut\naway, while the view stores where the plane stands (`clip.at`, in millimetres), so a model\nedit leaves the plane where it was and the slider reads 0% or 100% if the plane ends up\noutside the new box. Explode is offered only where there are parts to separate: a STEP\nassembly and a GLB of several rigid nodes. A STEP part file has no Explode tile, as it has no\nModel tree (`STEP_PART_VIEW_FEATURES`).\n\n## The file sidebar and the tool stack\n\n**The file sidebar** (`kit/tools/FileSidebar.jsx`) holds what there is to read of the file,\nwhatever tool is in hand — the strip is for using tools, the sidebar for reading the file: a STEP\nassembly's **Model tree** (a part file, whose tree would be its one row, has none), then\n**Position**, where the file has joints to move; a robot's **Links**, an `.sdf`'s **SDF**, then\n**Position**, where a joint can be driven; a drawing's **Layers**, where it has four or more\nlayers or any block reference (a flat pattern's CUT and BEND has none): each layer's pen, name,\nentity count and eye, its block references under it; and, after them, the shell's own **Animation** for a\nfile with routines (`kit/tools/playbar/AnimationSection.jsx`). A file with none of them has no\nsidebar. It is a toolbar of its own at the view's top-left, 8px in, on the strip's line and of\nits surface, padding and 24px tiles (`data-file-sidebar-tools`, \"File sidebar\"): one tile per\nsection, a tree's a tree icon named for it (Model tree, Links), Position's a spline, Animation's a\nclapperboard, any other section's a page named for it. Preview offers its Position and\nAnimation alone, their tiles bare in its top-left corner; the rest stay mounted, out of sight.\n\n- **One section at a time.** None is open as a file opens. A tile opens its section in a panel\n  under the toolbar, 8px below it, and is pressed while it is open; another tile opens its\n  section in the panel instead; a second press of its tile closes it, and so does the X that\n  ends the section's head. The panel slides in from the left as a section opens with none open\n  before, and back out as the last closes (200ms, none under reduced motion); from one section to\n  another it stays where it is. Closed, the panel is out of reach (`inert`). Every section stays\n  mounted, open or not, so a tree keeps its expansion, search and scroll. Nothing folds: a\n  section is its head and its body.\n- **Sections.** Each section is a `ToolPanel` drawn as one: a 24px head — its name 12px in from\n  the panel's edge, what its body puts there (`ToolPanelHeadAction`: Position's and Animation's\n  Reset), then its X — and its body. A tree's head is its filter instead of its name, the X\n  after it. The section is its content's height and the panel as tall as it is, up to its cap;\n  past that the whole panel scrolls, as one, the head pinned to its top\n  (`data-pinned`). A tree's rows are mounted as that scroll brings them into view\n  (`kit/inspector/VirtualRows.jsx`).\n- **Filter.** A tree is headed by its filter (\"Filter model\", \"Filter links\": \"Filter…\"), in place of a title,\n  always there, a tree row's 24px and 11px text, its icon over the rows' glyph column\n  (`primitives/tree-filter.jsx`'s `TreeFilterInput`, `dense`), its X clearing it once something\n  is typed, Escape too. The model tree's filter is followed by \"Loading…\" while a part's faces load\n  and **Show all** while parts are hidden. Every tree's search is one ranked flat search\n  (`kit/inspector/modelTreeSearch.js`).\n- **Size, each section's own.** A section's panel opens a tool panel's width\n  (`TOOL_PANEL_WIDTH`, 164px) and as tall as its section, capped at the room under the toolbar.\n  It is sized from its bottom-right corner alone, by Quick Edit's grip (`kit/tools/ResizeGrip.jsx`):\n  width and cap together, by pointer, or by keyboard (a separator named \"Resize file sidebar\":\n  Left/Right nudge the width and Up/Down the cap by 16px, Home and End take both to their\n  bounds). Its width runs from 120px (`FILE_SIDEBAR_MIN_WIDTH`: room for a heading's name and its\n  buttons; a tree's names truncate) to half the viewer, its cap from 64px to its room. One write,\n  when the gesture lets go (or per key), never per pointer move; each section keeps its own size\n  (`sidebar:<id>`), so the model tree may be wide and tall while Position stays narrow.\n- **On a phone** the sidebar is one touch tile, **Sidebar** (a panel icon,\n  `data-file-sidebar-toggle`), at the left of the strip's line, the strip padded clear of it on\n  both sides and keeping to its tools (`FileSidebar`'s `phone`). It opens one panel on the section\n  last open (the first, at first): its top a row of tabs, one per section with its tile's icon\n  (shadcn's `ToggleGroup`, `data-file-sidebar-tab`), and the panel's one X at that row's right end\n  (a section's head then has none; a panel of one section has no tabs and keeps its head's X).\n  The panel runs down to the view's bottom inset, or is as tall as its section if that is less,\n  stopping above Quick Edit's peek (`--cad-quick-edit-peek`), at a phone's own width (288px,\n  within the view's inset), with no grip. Its text is the panel's one size (13px, as the\n  Display menu's), its tree rows 36px with 16px glyphs, its filter, dropdowns and value fields\n  32px (`globals.css`). In Preview its tiles are its top-left's, as on a desktop.\n- **The layout is the person's.** The section sizes are one of the tab's settings, across its\n  files (`CadPreferences.toolStack`: `{ panels: { [id]: { width?, height? } }, collapsed: { [id]: boolean } }`,\n  a section's panel under `sidebar:<id>`, kept beside the appearance; any other key a stored\n  record carries is dropped). `panels` holds only what a person set; `collapsed` is the tool\n  stack's folded panels. Which section is open is not kept: a file opens with none. A new tab (a\n  cleared record) puts everything back at its defaults.\n\n**The tool stack** (`ToolStack`) is the column under the strip and the sub-toolbar, centred\nwith them, for a renderer's own `toolPanels`. STEP and the robot renderer hand none: their\ntools' controls are sub-toolbars and what there is to read of their files is the sidebar's.\nIts panels follow these rules:\n\n- **Two kinds of panel.** A `resizable` panel is the person's to size, on its own; every\n  other panel is *fixed*: one width, its content's height, and no grip.\n- **One width.** Every panel opens at `TOOL_PANEL_WIDTH`: 164px, a strip of six tools (six\n  24px buttons, 2px gaps, 4px padding and a 1px border). A fixed panel is exactly that wide; a\n  resizable one is only ever made wider, up to half the viewer. The panels hang left-aligned\n  in the column, each at its own width. Content truncates to fit; it never widens a panel.\n- **Heights.** A panel is exactly its content's height — never padded to a minimum. A\n  resizable panel has a *cap* the content grows up to and then scrolls inside: it opens capped\n  at the stack's own height (`toolPanelDefaultHeight`). A cap is never a floor.\n- **One grip, on a resizable panel only**, the sidebar's: its bottom-right corner, Quick\n  Edit's grip, moving its width and cap at once, a separator in the tab order named after its\n  panel, keyed as the sidebar's is. A folded panel has no grip: there is no height to set.\n- **Never past the viewer.** The column stops 8px above the cube's line\n  (`VIEWPORT_STACK_BOTTOM`, `calc(6rem + 16px)`). When the panels need more, a `\"tree\"` panel\n  gives way first, down to 128px or its content, then the `\"details\"` panels together, each in\n  proportion to its height, down to 96px or its content; a `\"fixed\"` panel keeps its height.\n  If what cannot give way still does not fit, the column itself scrolls — a panel is never cut.\n- **Headings.** A panel's heading row reads as Display's section headings do (11px, regular,\n  `TOOL_PANEL_HEADING_TEXT_CLASS`), 28px tall, 8px in: a title, a summary where there is one,\n  then at its right end its own actions, the chevron where it folds (up while open, down while\n  folded) and an X where it has something to remove. Every small button in a heading is 20px,\n  2px apart and 4px from the edge. A panel can end in one full-row button under its body,\n  which never scrolls (`footer`, `ToolPanelFooterButton`).\n\n- **Surfaces.** Three, defined once (`lib/floatingSurface.js`), with one border: the\n  tool strip, its sub-toolbars and the stack's panels, which stay up beside the model, share\n  `FLOATING_CHROME_SURFACE_CLASS` — the background at 30% and barely blurred\n  (1px), so the model behind them is easy to make out; every menu and popover\n  over the viewport, the app menu and Display included, share `FLOATING_SURFACE_CLASS` (the\n  background at 75%, blurred), so their text never competes with the model; and the file\n  sidebar, mostly text but up for as long as the file is open, is between them\n  (`FILE_SIDEBAR_SURFACE_CLASS`: 55%, a 4px blur).\n- **Scrolling.** Every scroll region in the viewer's chrome — the file sidebar, a panel's\n  body, the stack's column, the explorer, a menu, the alert card — is the\n  `ScrollArea` primitive (`primitives/scroll-area.jsx`, shadcn's): thin overlay\n  bars in the theme's colours, shown while the pointer is over the region. No\n  native `overflow-auto` scroller in chrome; `src/designSystem.test.js` holds it.\n  The home (the model library) is a page, not chrome: it scrolls as a page does,\n  with the platform's scrollbar, and only when there is more of it than fits.\n- **Nothing opens elsewhere.** A pick shows as Quick Edit's chips; the sidebar is on screen\n  under every tool. No pick or tool opens or closes the explorer, on desktop or mobile.\n\nTree rows inset their backgrounds 4px from the panel's edges, and the search box\nshares that inset. A tree in the file sidebar (Model tree, Links) is dense (`TreeRowSurface`'s\n`dense`): 24px rows in the panels' 11px text, and ONE light 12px glyph per row in one\ncolumn, as the explorer has: a subassembly's or folded group's disclosure chevron, turned down\nwhile open, or a part's box (Links draws its chevrons alone), 4px before the name. The glyph\ncolumn starts 5px in from the row's left end (`TREE_ROW_INSET_PX`), centred under the find\nfield's search icon and as far in as the icons of a row's actions sit from its right end, in the\nSTEP tree and the explorer alike; each level adds\nthe explorer's compact indent, 10px (`TREE_INDENT_PX`), its rows hung from a faint line under\ntheir owner's glyph (`TreeRowGuides`), as the explorer's are; its search box is its section's head. An assembly row's\nactions, shown on hover and kept while they are on, are **Isolate** then the **Hide/Reveal** eye; a part file has no\nIsolate. A Links row that draws something — a link with visuals, or one visual —\nhas the eye alone, in its search hit too: a link's hides its own visuals, never its\nchild links, and on a partly hidden link hides the rest. They float over the row's right end rather than taking width from it:\nthe name runs the row's full width and, while an action shows, fades out half a\nrem before them (a mask, `ROW_NAME_UNDER_ACTIONS`), so nothing is drawn behind the\nbuttons and the row keeps its own colour.\nModel and link finds and the explorer's filter share `TreeFilterInput`; the explorer's rows are compact\n24px lines, as the trees' are, but in 12px text.\n\n**Mobile** is below 600px of FileViewer width (Material's compact window class) — the one viewer breakpoint\n(`useViewerMobile`); chrome never uses window breakpoints, nor the pointer's kind: a small window\nis worked as a phone is. What a finger reaches is the gesture's own (`pointerType`): a touch takes an\nedge from 20px off (an edge within 16px wins over the face under it), ink, a measurement and the\neraser's ring from 16px (`TOUCH_HIT_PX`), and a joint's knob from 22px; a mouse keeps its few px.\nOn a phone every slider is a 36px band with a 20px thumb, and the selected ink's bar has 36px\nbuttons. A pinch zooms at the touch speed from the first gesture after a load. A phone is read at arm's length\nand worked with a finger, so its chrome is one step up the scale, after the platforms' own\nguidance (Apple's 44pt and Material's 48dp touch targets, an 11pt type floor and 15-17pt body\ntext, and iOS Safari zooming into any field under 16px): type 12, 13, 14 and 15px for `text-micro`,\n`text-tiny`, `text-xs` and `text-ui`; text entry 16px; menu, select and tree rows 36-40px; the\nnavbar a 48px row of 36px controls; the strip and the sub-toolbars 36px tiles (the strip one row,\n**More tools** holding what does not fit; the drawing tools' row the same single row, wrapping where it must).\nThe scale is set once, in `globals.css`, for the whole page while a phone's view is on it, so a\nportalled menu, select or hint is the same size as the chrome it opened from. There is no view\ncube; the file sidebar is one tile at the strip's left, its sections tabs of one panel; and\nQuick Edit is a bottom sheet (see\n[Quick Edit](#quick-edit)). The explorer is the same popover, never wider than the window less\n1rem. The shortcut hint is hidden.\nTouch works for every tool: one finger orbits, two pan and\nzoom, a tap picks; a pinch, a cancelled pointer or a camera drag is never a\npick.\n\n## Display settings and section primitives\n\nDisplay is not a tool. Its tile ends the strip after the strip's rule, between Projection and\nPreview, and shows the current mode's sphere icon. It opens a stock shadcn dropdown menu\n(`DisplayPopover.jsx`): a muted **Display** label, the modes as a `DropdownMenuRadioGroup`,\na separator, then **Grid**, a `DropdownMenuCheckboxItem`. On a phone the strip's **More**\nmenu has a Display row whose nested submenu is the same list (`DisplayMenuItems`), with no\nlabel. Checks sit at the row's right end. Choosing a mode closes the menu; ticking Grid\ntoggles it and leaves the menu open (`onSelect` prevents the close). Clicking outside or\npressing Escape closes it. Opening it leaves the active tool and its controls in place. A\n2D view has no Display or Preview control.\n\nDisplay is a choice of mode and the Grid switch, and nothing else: a mode has no settings\nof its own, and there is no settings page. The modes, in order, are **Solid** (shaded with\nthe model's CAD edges, the default), **Shaded** (no edges), **Parts** (Solid with each part\nits own colour), **X-ray**, **Hidden line** and **Wireframe**, filtered by the view's\ninjected capabilities (`viewFeaturesForKind`, `DisplayModeOptions.js`): a STEP assembly\noffers all six, a STEP part file all but Parts, and a file without CAD edges (STL, 3MF, GLB,\na robot) Solid, with Parts when it has several parts, objects or links. **Grid** draws the\nmeasuring grid in every mode, off by default. Each row has its icon, the sphere drawn as the\nmode draws a model (and a tile of floor grid for Grid). Render is no display mode: Preview\ndraws the model in Render whatever the file's mode, with its own settings (see\n[Preview](#camera-animation-and-preview)). Per-file storage keeps the mode and the grid;\nold-shaped stored display settings are dropped setting by setting, never migrated.\nProjection is a menu of its own, shared, orthographic by default; Preview's Render resolves\nto perspective while preserving the shared choice.\n\nPreview's settings panel (`PreviewSettings.jsx`, its body `PreviewRenderSettings.jsx`) is\nthe one settings form: Orbit, then Render's Part colours, Lighting, Background and Floor.\nIt shows all groups under static section headings and dividers, with no collapsible\nsections or cascading settings menus. A heading is 24px of regular `text-tiny` in the\nforeground colour, a rule above every one but the first. A feature that switches on and off\n(Orbit, Floor) is the checkbox at its heading's right end, the title naming it; there is no\nseparate row for it. While the feature is off its section is that heading alone. Every\nsetting is one 24px row: its label in a 4rem muted column, its control filling the rest, so\na form's controls start on one line. A label is short under its heading (\"Colour\",\n\"Opacity\" under Floor); the control's accessible name stays whole (\"Floor opacity\"). A\ndropdown or colour is as wide as its value, at the row's right end. Sliders are shadcn's,\nas everywhere, with a committed value field after them. By part's palette is a row of\nswatches, then add and remove. Choices use Select controls. Continuous values use compact\nslider and committed-number fields; their keyboard input stays with the field. Colours\nretain swatches and a custom colour picker. Floor is on by default, at the model's Z=0\nplane. A long form scrolls within the available viewer height. A nested Select or colour\npicker owns Escape and outside interaction before its parent surface. The last ite\n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: packages/ui/docs/view-updates.md (Tokens: ~1138 | Category: anthropic-skill) -->\n# Responsive View updates\n\nView controls always edit the per-file `viewSettingsStore` immediately. That\nstore is the only settings authority. A mode is a preset batch; the renderer\nnever branches on its name. Rendering completion cannot rewrite controls,\npersistence, feature gates, or the Custom label.\n\n`kit/view-settings/viewUpdateCoordinator.js` owns one active operation and one replaceable\nrequested scene recipe. `kit/view-settings/useAppliedViewSettings.js` binds it to React.\nRapid edits replace pending work; they do not accumulate in a queue. Preparation\nis cancellable. Once scene reconciliation starts it finishes serially, but a\nsuperseded result is not presented. File changes/unmount cancel work and reject\noutstanding captures. Errors leave controls editable and expose Retry.\n\n## Cost policy\n\n`kit/view-settings/viewUpdatePlan.js` is the single classification point. Compare resource\nboundaries, never preset names:\n\n| Preparation | Live update |\n| --- | --- |\n| Lighting enable, quality, softbox size/fill | Exposure, rotation |\n| Surface style/color source, crossing opaque/transparent | Surface color, opacity within one transparency class |\n| Edge enable/visibility | Edge color |\n| Floor enable | Floor color, opacity, placement |\n| Clip enable/disable | Clip position, Reverse and Part colors after preparation |\n| Appearance shader change | Projection, background, grid, axes, Explode |\n\nBoth paths yield until the controls have painted, then apply the latest recipe.\nOnly the preparation path loads resources and compiles shader variants. Live\nupdates coalesce to the next frame and do not wait for an artificial debounce.\n\nThe Clip and Explode tool buttons add neutral control panels. Editing a slider or\nnumber enables the effect; X or pressing the tool again resets and removes it.\nClip uses original model bounds for its slider and the rendered plane, independent\nof pose and Explode. Its neutral endpoint is the maximum coordinate (minimum when\nflipped). It has one active axis and plane, exposed in its panel as a compact X/Y/Z\ndropdown and a single cut-amount slider. The slider starts neutral on the left and\nadvances from maximum to minimum coordinate; a flipped cut (`invert`) reverses that direction. CLI coordinates, including zero, remain supported. Clip and Explode stay outside mode presets.\n\n## Renderer and resources\n\n`kit/look/studioEnvironmentCache.js` keeps at most three reflection maps per\nviewport. `studioEnvironment.worker.js` generates PMREM in worker WebGL and\ntransfers half-float pixels; model geometry stays on the main renderer. Rotation\nand exposure reuse the map. Superseded work terminates the worker; completed\nmaps are reused by key. Workers terminate after transferring their pixels, so inactive tabs do not\nretain extra WebGL contexts. Disposal releases textures and any pending worker. Browsers\nwithout OffscreenCanvas/Worker use a synchronous compatibility path after the\ncontrols paint; the full responsiveness benefit requires worker WebGL.\n\n`kit/view-settings/viewUpdateGate.js` preserves the previous canvas framebuffer while the\nscene is reconciled and Three's `compileAsync` prepares its programs. The\nrenderer/context and cached geometry stay mounted. `viewportBuffer.js` queues\ncanvas resolution changes and flushes them inside the next actual draw callback.\nChanging DPR or dimensions outside that callback clears the retained frame, so\nno quality effect or resize observer may resize the backing buffer directly. A viewport\nresize DRAWS that frame instead, synchronously inside its ResizeObserver callback (after\nlayout, before paint): the canvas is sized 100% by CSS, and a frame requested from there\nwith `requestAnimationFrame` would land a frame late, painting the old picture stretched\nover the new box whenever the viewer changes width in one step (a window snap). That synchronous frame replaces any queued one, so a drag still draws once\nper frame, and an idle viewer draws nothing. While the gate holds, the draw is skipped\nlike any other, the buffer keeps its size, and the held picture waits for the frame that\nreleases it (`kit/viewport/ViewportResize.browser.test.mjs`). Presentation resumes after\npreparation; completion is acknowledged after an actual draw. Camera input is\naccepted during preparation, though the displayed pose can briefly wait while\nthe scene is held. Shader support and GPU drivers can still impose synchronous\nwork (notably shadow allocation/first draw); this is not a second renderer or a\npromise that all GPU work is preemptible.\n\nScreenshot/prompt capture awaits the coordinator's latest presented revision,\nso it cannot accidentally capture a half-applied recipe. Ordinary snapshots\noutside FileViewer retain their own scene setup lifecycle.\n\n## Status placement\n\n`kit/status/ViewUpdateStatus.jsx` only takes `status`, `onRetry`, and\n`className`. It has no scene/store knowledge. RendererShell draws it centred under the tool\nstrip and the tool in hand's sub-toolbar, or at the top of the viewport in the strip's row\nwhere there is no strip (preview, a load), without changing scheduling. It appears after 150ms with “Updating view…” (or\n“Preparing section view…” for Clip), stays nonmodal, and never disables controls.\nFast changes produce no spinner. Failures show a retry action.\n\nTests cover latest-request wins, cancellation back to the visible recipe,\nserialized reconciliation, failure/retry, capture readiness, disposal, and\nStrictMode/control independence. Real browser checks must also exercise cold\nRender, rapid preset/clip edits, a completed capture, and a warm return.\n\n<!-- File: packages/ui/docs/viewer-host.md (Tokens: ~9680 | Category: anthropic-skill) -->\n# Viewer host contract\n\n`FileViewer` requires one explicit `ViewerHost` from `@text-to-cad/ui/host`.\nShared UI implements rendering and document interaction; apps supply environmental\neffects. No shared feature discovers Electron, browser clipboard, a backend URL,\npersistent storage or page navigation. DOM, canvas, workers and layout remain\nshared. Missing optional methods mean an operation is unsupported.\n\nThe host contains `files`, optional native `fileActions`, `clipboard`,\n`promptContext`, the optional `promptFiles`, `navigation` (the optional `openFile(path)` and\n`home()`), the optional `links` (the app menu's), the optional `usage`\n(`used(feature)`: what the person used, for the host's telemetry to count, never what\nthey made; absent, nothing is counted) and `environment`. `environment` carries the resolved `colorScheme`,\nthe resolved `locale` (the language the view reads in: the person's choice, else what the\ncontext infers, as `@text-to-cad/ui/i18n`'s `useLanguage` resolves it; none, or one the viewer\ndoes not speak, reads English),\nthe keyboard `platform` (`darwin` shows ⌘, anything else Ctrl), the app's own\n`reducedMotion`, honoured beside the system's `prefers-reduced-motion`, and\n`compact`: a view drawn as a picture (the home draws its thumbnails this way, out\nof sight), where a CAD renderer draws the model alone — no tools, view actions,\nview cube or Quick Edit — and FileViewer draws no navbar. A view shown small in a\nconversation is not compact: it is the whole viewer, and its host hands it a way\nto full size as `fullSize` (`CadViewerProps.fullSize`), among the last of the navbar's\ncontrols and the home's, before the host's colour scheme control. CAD is a separate registration supplied with a\n`CadWorkspaceService`; the generic FileViewer does not import CAD. The HTTP CAD\nadapter can serve both apps, while desktop owns native runtime startup/recovery.\nSee [workspace resources](../../core/docs/workspace-resources.md) for resource\ntickets and cache identity.\n\nA host passes no tolerance to the STEP renderer: it draws each part at the display mesh its\npart's record names, which cadgen derived at its display tolerance\n([the display mesh](loading.md#1-the-display-mesh)).\n\nApps create services for the workspace lifetime. File tabs borrow them, while\nmounted renderers own scenes, document controllers and temporary resource leases.\nUnmounting one view releases its work without disposing another view's services\nor admitted cache writes. Hosts dispose a service when its workspace closes.\n\n## Interface map and implementations\n\nThe linked TypeScript definitions are the authoritative signatures. Import\ntheir compiled public package exports in application code; these source links\nare for reading and maintaining the contracts.\n\n| Contract | Definition | Public entry point |\n| --- | --- | --- |\n| `ViewerHost`, `ClipboardPort`, `PromptFiles` (a Quick Edit's views and annotations, saved as JSON), `usage` | [Host types](../src/host/types.ts) | `@text-to-cad/ui/host` |\n| `FileSource` (`stat`, and `list` and `search` where the host has them), `FileActions`, `FileViewerState` | [File viewer types](../src/file-viewer/types.ts) | `@text-to-cad/ui/file-viewer` |\n| `PromptContextPort` (`deliver`, `send`), bundles, references, delivery receipts and `formatPromptMessage` (the one message a Quick Edit is) | [Prompt types](../../core/src/prompt/types.ts) | `@text-to-cad/core/prompt` |\n| `CadWorkspaceService` (the catalog of the files on screen, `folder`, `search`), `createCadClient` (`{ origin, fetch, … }`), `isMissingFileError`, `CadResourceProvider`, worker tickets | [CAD service types](../../core/src/client/types.ts) | `@text-to-cad/core/client` |\n| `createHttpPromptFiles` (the viewer server's `PromptFiles`: `POST /__cad/prompt-files`, writes JSON and returns paths) | [Prompt files](../../core/src/client/promptFiles.js) | `@text-to-cad/core/client` |\n| `StepRendererOptions`, `CadLiveBinding` | [STEP registration](../src/renderers/step/index.ts) | `@text-to-cad/ui/renderers/step` |\n| `TabStore`, `TabRecordStorage`, `createTabStore`, `useTabViewerState` (the tab's one store: its settings, the view of the file on screen, and `FileViewer`'s state from both) | [Tab store](../src/tab-store/tabStore.ts), [the record](../src/tab-store/tabRecord.ts) | `@text-to-cad/ui/tab-store` |\n| `CadPreferenceSource`, `createCadPreferences` (the tab's settings as renderers read them) | [Viewer preferences](../src/renderers/workspace/preferences.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `DxfRendererOptions` (2D drawings; declines every camera, display and selection command) | [DXF registration](../src/renderers/dxf/index.ts) | `@text-to-cad/ui/renderers/dxf` |\n| `GlbRendererOptions`, `LiveViewBinding`, `LiveViewController` | [GLB registration](../src/renderers/glb/index.ts), [live binding](../src/renderers/kit/shell/liveBinding.ts) | `@text-to-cad/ui/renderers/glb` |\n| `MeshRendererOptions` (STL, 3MF), `LiveViewBinding`, `LiveViewController` | [Mesh registration](../src/renderers/mesh/index.ts) | `@text-to-cad/ui/renderers/mesh` |\n| `RobotRendererOptions` (URDF, SRDF, SDF), `RobotLiveController`, `RobotLiveState` (`selectedLinks`, `selectedPartIds`) | [Robot registration](../src/renderers/robot/index.ts) | `@text-to-cad/ui/renderers/robot` |\n| `ViewerCommands`, `ViewerCommandSource` (the host requests every viewer renderer takes) | [Viewer commands](../src/renderers/workspace/commands.ts) | `@text-to-cad/ui/renderers/workspace` |\n| `createLiveRegistry`, `LiveRegistry` (the host's handle on the mounted view: its renderers' `live`) | [Live registry](../src/host/liveRegistry.ts) | `@text-to-cad/ui/host` |\n| `ModelLibrary`, `ModelLibrarySource`, `useModelThumbnail` (a host's home, under the navbar: the CAD title and byline over the host's links (GitHub, Discord, X), then the models opened before, pinned first, as cards or rows, and with none, where the host has a chooser, an \"Open File\" card that opens it; placeholder cards or rows while the list is read (again every two seconds while the home is up, so a model rebuilt meanwhile is pictured again), and a spinner over the model being opened, which takes no second press until `open` settles; `pick` only where the host has a file chooser) | [Model library](../src/library/ModelLibrary.tsx), [thumbnails](../src/library/thumbnails.ts) | `@text-to-cad/ui/library` |\n| `CadViewer` (FileViewer over a CAD client, one file by absolute path or the home: the five CAD renderers, catalog following, the home, the \"File does not exist\" and \"Could not open that file\" pages, the library's pictures) | [CAD viewer](../src/cad-viewer/CadViewer.tsx) | `@text-to-cad/ui/cad-viewer` |\n| `createCadFileSource` (a CAD client as a read-only `FileSource`: `stat` through the client's `resolveEntry`, `list` through `folder`, `search` through `search`), `createCadFileActions` (the file menu's Copy path, the absolute path, and Reveal), `normalizePath`, `baseName`, `joinPath`, `contentRevision` | [Catalog](../src/cad-viewer/catalog.ts) | `@text-to-cad/ui/catalog` |\n| `ViewerLinks`, `viewerLinks` (the version, GitHub, Discord, where a new issue opens, the newest release and how to update, and how a link is followed), `issueUrl` (a new issue filled in — its title, labels and body — its address kept under `ISSUE_URL_MAX`) | [Host types](../src/host/types.ts), [links](../src/file-viewer/navigation/links.js) | `@text-to-cad/ui/links` |\n| `buildId` (for an app's Vite config: which build this is, \"\" for the release's own, whose environment names its version in `TEXT_TO_CAD_RELEASE`, else the checkout's commit) | [Build id](../scripts/build-id.mjs) | `@text-to-cad/ui/build-id` |\n\nStart with the actual compositions: [web App](../../../apps/web/src/App.tsx) and the\nMCP app's [ModelView](../../../apps/mcp/src/ModelView.tsx), both one `CadViewer`.\nIts imports lead to the app-owned `host/`, `adapters/` and persistence\nimplementations. [Web storage](../../../apps/web/docs/storage.md) documents\nbrowser lifetimes. Shared component tests can\nuse the [explicit fake host](../src/host/testing/host.ts).\n\n## A CAD renderer's controls\n\nThe controls of a CAD renderer (STEP, GLB, mesh, robot) are its own, over the viewport: its\ntool strip, the tool in hand's sub-toolbar and its file sidebar, its view's controls (Display,\nPreview) drawn into the navbar's `navbarSlot` (`settings-ui.md#the-file-sidebar-and-the-tool-stack`); a\nDXF's are its own canvas's. Nothing a renderer does opens or closes the explorer (only the\nfile's name opens that). The layout a person gives the file sidebar — each section's size —\nand the tool stack's panels is one\nof the tab's settings (`settings.toolStack` of the tab record, `@text-to-cad/ui/tab-store`),\nbeside the appearance and the home's layout.\n\n## Preview and renderer navigation actions\n\nPreview is the shared shell's own state (`previewing`); there is no host\nprop for it and a host cannot start or observe it. It is a 3D view's alone, and each\nrenderer says whether its view is one: it declares `previewable` to the shell\n(`useRendererShell`), as STEP, GLB, STL/3MF and URDF/SRDF/SDF do. A view that does\nnot — a DXF's 2D drawing, which draws its own surface without the shell — has no\nPreview at all, no control and not a disabled one, and nothing that asks for it\n(a link or a host request, should either come to) takes it there: it keeps\nthe normal view. The CAD app's Full size, inline, is the host's display mode, not\nPreview: it shows the normal view, of any file. Preview keeps the navbar, so the app\nmenu and the explorer still lead to other files; the host is not told it is on.\nPreview and the tools view are two states: entering leaves the tools view's state as it\nis (the pose, the routine, the selection, hidden and isolated parts, Explode and Clip,\nmeasurements, the camera, the tool and its panels) and Preview starts from the model as\nauthored, with its own camera, still, and its routine at rest, drawn in Render whatever the\nfile's display mode (the tools view never draws Render); leaving throws Preview's state\naway and gives the tools view back exactly as it was, its routine included, in the file's\nmode. Preview is a view of its own, mounted over the tools view's while it is open\n(`kit/shell/PreviewView.jsx`), with its own scene, camera, pose and routine, sharing only the\npage's GPU device; the tools view's viewport stays mounted under it and nothing of it changes.\nNothing of Preview is persisted. Routines play from the file sidebar's Animation section in either\nview. Preview opens still: Orbit is off every time it opens, and its settings panel turns it\non. The file's view's `playback` — Orbit's speed, Autoplay, and the chosen speed and loop —\nis kept between visits to Preview and across a reload, and a file's routine plays on entry\nonly when its Autoplay is on. The rules are in\n[settings-ui.md](settings-ui.md#camera-animation-and-preview).\n\nA renderer can publish `FileNavigationAction[]` through\n`RendererViewProps.onNavigationActionsChange`. The CAD renderers publish one, and only\nwhile a person has put away an alert card the model survives: the card's own icon in its\ncolour, named after the alert, which brings the card back (`useAlertDismissal`,\n`kit/status/ViewerAlertCard.jsx`). The\nshared navbar shows these at its right, after the host's update button and before the\nhost's Full size and colour scheme control. Each action declares its icon, accessible label, an\noptional shorter hover `hint`, disabled state and invocation callback. Registration belongs to the mounted\nfile generation: publish an empty list on cleanup; departing renderers cannot\nreplace a new file's actions. Publish only when action metadata changes; stable\ncommands should read the current viewport through a ref, avoiding parent/child\nrender loops. These actions use existing host capabilities for effects. What a\nperson tells the agent about a CAD file is [Quick Edit](#prompt-handoff)'s.\n\nA renderer's controls for the view are the renderer's own, drawn into the box FileViewer\nhands it at the navbar's right end (`navbarSlot`): the CAD viewer's for a 3D view, Display and\nPreview, and in Preview its Preview settings and Exit preview; the navbar stays over it.\nWhere there is no navbar (a view shown small) the renderer draws them over its view. The host's `captureRequest`\ncommand is the same capture: to a composer destination it delivers the view and\nthe selection through `promptContext.deliver`. Neither route detects the platform.\n\n`navigation.openFile(path)` shows a file, by its absolute path, in this view: a pick\nin the explorer, a renderer's link. `navigation.home()`, where the host has a home,\nshows it in this view: from a file, the app menu's Back to files (the menu the navbar's\nlogo opens) leads to it; over the home the same navbar has the logo and its menu, with no\nBack to files. `CadViewer` implements both\nover the host's `onShow(path)` (`''` is the home), and gives a host a home only with a\n`library`: a host whose own navigation shows one file (Codex's file handler) passes none,\nand its view shows that file and nothing else — no home, its menu no Back to files, no\nexplorer, and no `openFile`, so a renderer's link to another file (a robot's mesh) is plain\ntext there. A host that keeps a view's own history,\nwhere no browser does (the CAD app's views), hands it as `history` (`ViewerHistory`:\n`canGoBack`, `canGoForward`, `back()`, `forward()`; `CadViewerProps.history`): the navbar\ndraws Back and Forward over it, over a file and over the home alike. What the history holds is\nthe host's (the CAD app's: every file the view showed and the home, a step like a file, the\nagent's shows and the person's moves alike); the web hands none, its page having the\nbrowser's, where the home is a step too. `onShown(path | null)` says which file the catalog\nhas on screen (null for the home, or a file still resolving or missing), for a host that\nnames its page after it or records it. The host decides what showing a file means,\nbecause only the host knows which view shows it: the web viewer is one page per machine\nand writes the file into its URL (`?file=`, a new history entry, so the browser's back\nand forward move between files, and a bare URL is the home), and the CAD app, which\nrenders in Codex (the sidebar, a tab beside each thread and a file handler) and inline in\nClaude Desktop and other MCP Apps hosts, shows it in the view that asked or, from the\nhome, launches it.\n\n## Adding a shared feature\n\n1. Implement reusable interaction and presentation in UI, with cross-consumer\n   non-React domain behavior in core. Renderer-private helpers stay with their\n   renderer in UI. Keep project, session and operating-system workflows in apps.\n2. Reuse an existing injected contract. If a new environmental effect is needed,\n   extend its narrow consumer-owned interface and implement it in each app, or\n   explicitly advertise that the host cannot perform it. Shared UI must not\n   fall back to browser globals or branch on `isWeb`/`isDesktop`.\n3. Use subscribed capability/destination state for availability and labels:\n   Quick Edit offers only what the destination and the prompt port can do. Use\n   a named additive slot for an extra app-enabled interface; the shared\n   renderer never imports the app's component or stores.\n4. Define identity, lifetime, cancellation and result semantics with the\n   contract. Publish serializable view state through the controlled binding;\n   the app chooses its storage. Keep workspace services stable across tab mounts.\n5. Update this contract or its linked domain guide, add focused shared/adapter\n   coverage and verify affected host integrations. Exercise source changes and\n   late results for asynchronous effects, and warm reuse for resource changes.\n   Run `npm run check:boundaries` and rebuild compiled packages before app checks.\n\nPlatform-agnostic UI can use DOM, canvas, React and renderer-owned workers.\nFilesystem access, transport selection, credentials, clipboard, persistent\nstorage, page navigation and native process lifecycle remain host responsibilities.\nSo do a transport's limits: a host whose channel caps one reply declares the\nmost one batched read may ask for (`createCadClient({ maxBatchBytes })`), and the\nshared loader never asks for more; the host carries a longer body in parts (the\nCAD app's tunnel reads it a range at a time), and the loader sees it whole.\n\n## Prompt handoff\n\nContext actions use `PromptContextPort.deliver(context)`. Desktop\ninserts into a compatible draft; web prepares clipboard representations. Ordinary\nexplicit copy/paste controls use the separate `ClipboardPort`. Delivery never\nsubmits a prompt. `send(context)`, present only where the host has a chat to post\nto, posts the context as the person's message now (`sent`); absent, nothing can\nsend one.\n\nThe portable types and validators live at `@text-to-cad/core/prompt`. One versioned\nbundle contains ordered text, reference and attachment parts with unique IDs.\nAn attachment has a MIME type, name and Blob or Promise of Blob. Its optional\n`about` array names reference-part IDs, so a screenshot and several selections\ncan travel together, its optional `label` (\"Annotation\") says what it is for a\nmessage that names it by path, and its optional `path` (a path, or the promise of\none) is where it is already saved: `resolveAttachmentPaths(context)` waits for\nthem all. Producers freeze resource and selection identity before\nasynchronous capture. Blob URLs are delivery leases, never portable identity.\n\nA reference contains a workspace-file identity (the file's absolute path,\n`/`-separated: `/a/b.step`, `C:/a/b.step`; validation rejects any other spelling)\nor HTTP(S) URL plus a tagged\nselection: `whole-resource`, `text-range`, or `cad-selector`. Text positions are\nzero-based UTF-16 with an exclusive end. CAD selectors use core's validated\ncadgen grammar, not STEP entity numbers. Preserve a revision when available;\nreferences do not promise to survive edits. Shared serialization handles quoting.\n\n`formatPromptMessage(context, { attachmentPath })` is the one\nmessage a Quick Edit is, whether sent, queued or copied: what the person wrote;\nthen `File: <path>`; then `References:` and one reference per line; then each\nJSON file by its label and path (`Views (cadgen snapshot --job): <job.json>`,\n`Annotation: <file.annotation.json>`), where `attachmentPath` says where it was saved.\nNo pictures are rendered, attached or sent.\n\n\n`PromptContextAction` (a host renderer's prompt action) reads the subscribed\ndestination and labels the action Add to prompt or Copy for prompt. It calls delivery during the\nuser gesture, before awaiting capture: browser activation and desktop destination\nbinding depend on this. Availability and advertised attachment/combination limits\nbelong to the host. Every result is acknowledged: added, copied, sent, partial,\ndeferred, cancelled or failed. `partIds` describes accepted/written parts, not a\nclaim that a different application pasted them.\n\nDesktop captures the compatible draft destination before awaiting attachments,\nvalidates the complete bundle and rechecks that destination before atomic draft\nacceptance. Switching chats cannot redirect an in-flight capture. Existing text\nand attachments survive; operation IDs prevent duplicate acceptance. Workspace\nmismatch uses the app's explicit Start chat here recovery. Invalid attachments\nleave the draft unchanged. Direct capture failure never shows success.\n\nWeb supports text/reference serialization and one PNG. A combined clipboard\nwrite reports that text and PNG are separate representations; some receivers\npaste only one. Unsupported attachment types or combinations fail explicitly.\nHosts must resolve/validate accepted attachments and consume failed encoders;\nthey do not transfer Promise or Blob values across native IPC.\n\nQuick Edit is the shared, host-neutral note to the agent\n([the design system](settings-ui.md#quick-edit)), and its buttons are what the host can carry out.\nEvery way it goes carries its words, references and a snapshot packet captured at\nCopy, Queue or Send. The packet includes the current view plus distinct drawn views\nwith remaining ink; an equivalent drawn entry uses the exact current framing.\nShared input and inline annotation ([annotation](annotation.md)) appear once; measurements\nare the person's and never go. Each job may override shared fields. Projection and lens are preserved.\nPackets up to 1024 bytes of compact UTF-8 JSON are inlined; larger packets are\nwritten as one temporary `.snapshot.json` and named by path. Hosts use\n`resolvePromptMessage` to resolve an attachment's optional `inlineText` before its\npath, keeping destination binding inside the gesture. Drawing, selecting and typing\nkeep data in memory. Nothing renders at the press. The agent saves inline JSON to\na temporary file before calling `cadgen snapshot --job <file>`.\n**Copy Prompt** is always there: the message goes through\n`ClipboardPort.writeText`, which takes a `Promise<string>` so the write starts\ninside the gesture while the files are still being made; its references name their\nfiles by absolute path, as copied references do (below), and every file is named by\nits path, since text carries no file (without `promptFiles`, a prompt carries none). **Queue** is there where the\ndestination is a composer (`destination.kind === \"composer\"`): `deliver`, the\ncontext for the person's next message. **Send** is there where the prompt port\nhas `send`. The context is built at the press, from what is live then; a changed\ndocument or revision never retargets it. A Quick Edit that reached its destination\n(copied, queued or sent: no `failed` or `cancelled` receipt) tells the host once,\n`usage.used('quickEdit')`, for its count; a host that throws there changes nothing.\n\n`ViewerHost.promptFiles` is a `PromptFiles`: `create({ name, input, views, annotation })`\nanswers `{ inline: compactJson }` or `{ snapshot: absolutePath }`, `read(path)` fetches JSON,\nand optional `save({ name, kind, data })` remains for older clients that export individual\nannotation files. Current chips do not call it. `createHttpPromptFiles({ origin, fetch })` from\n`@text-to-cad/core/client` posts to `POST /__cad/prompt-files`, which validates the packet in memory and returns inline JSON or writes the larger\nsnapshot and returns its path with HTTP 200. The client advertises `acceptInline: true`;\nrequests without it always receive a saved path, so already-open clients remain compatible. There is no render worker\nor polling endpoint. Guarded `GET /__cad/prompt-files/<name>` serves JSON only;\n`POST /__cad/prompt-files/save` supports those older clients. The files\nare scratch in the system's temporary directory and outlive the viewer until the\nsystem clears them. The response and persisted snapshot name input and output\nfiles by absolute path; annotation data is inline, so replay works from another\nworking folder. Both apps pass one; the MCP app's\nreaches the server through its tunnel.\n\n## App-specific interfaces\n\nThe optional CAD `live` binding receives a `CadLiveController` only while its\nviewport is mounted. `readState()` returns a detached serializable snapshot of\nthe actual resource/revision, current selections, camera, display and render\nmode. It reads the viewport camera directly, with its last camera snapshot as\nan unmount fallback. During a rebuild that retains a predecessor mesh, its\ndisplayed document revision remains in the snapshot until replacement. Apps\nown the binding registry and any IPC/tool transport;\nshared UI does not infer view state from a backend catalog or stored tab state.\n\nThe controller selects available selectors, clears selection, applies a camera,\nresets framing, applies grouped View settings, selects presets and captures a PNG.\n`thumbnail({ width, height })` is a library card's picture: it waits for the view\nto settle — the renderer's own live state saying the whole file is loaded and\ndrawn, never a timer — then draws the model framed whole from the default\ndirection at the card's aspect, on its own (no floor, grid or axes, whatever the\nperson turned on) and on transparency, so it suits either scheme, off to the side of the view, so\nthe person's camera, panels and window never show in it and nothing on screen\nchanges (`kit/viewport/thumbnail.js`; a DXF paints its fitted drawing on a canvas\nof its own). A view that goes before it settles rejects it. `useModelThumbnail`\nkeeps one per revision of a file per mounted view (a model rebuilt while it is\nopen is pictured again once its new revision is drawn), and only for the file the\nview still shows.\nOn the home, a card on screen with no picture, or one taken before its file last\nchanged (`pictured` against `modified`), is pictured out of sight wherever the host\nkeeps pictures (`onThumbnail`): `CadViewer` mounts a viewer of its own for it, over\nits own client — its own renderers and live binding, compact, behind the page at the\npicture's size — one model at a time, each once per visit, and only a model whose\nartifact status is already `compiled`, and keeps the picture through `onThumbnail`\nas it keeps the file on screen's. The home never builds anything: an unbuilt model\nkeeps its placeholder until a view shows it.\n`readState().display` and `setDisplaySettings(patch)` use the same sparse display\nschema as snapshots: `mode` selects `solid`, `shaded`, `parts`, `xray`, `hidden-line`,\nor `wireframe` (each a look at its own defaults), `grid` switches the measuring grid,\nand appearance, camera, clip and exploded are shared. Patches merge supplied fields;\na mode has no settings of its own, and a patch naming any is refused. There is no\nCustom state or legacy shape migration. Render is no mode: it is Preview's, which no\nlive command opens, so `mode: \"render\"` is refused as any unknown mode is; Preview's\nown settings are the viewer's panel's, not the display's. Camera projection/lens changes preserve viewport pose and zoom.\n`resetCamera` frames the model again without turning the camera — exactly what\nSTEP's context-menu \"Zoom to fit\" does. Cube shortcuts change only orientation.\n\nA file's view holds its camera: a mount restores it in place of the fit, and fits\nwhen there is none. Live\ncommands reject retired field names rather than maintaining a second display\nauthority. Unavailable selectors fail explicitly; topology is not silently\nloaded. Mutations return a view snapshot after the React frame; a mode switch\nwaits for the requested settings to commit, with a ten-second bound.\nLoading views\nreject commands, and an operation whose resource/revision changes or viewport\nunmounts before completion rejects its late result. On unmount, the controller\nreleases its component closure and retains only its final snapshot with\n`active:false`; every subsequent command requires showing the model tab first.\nHosts may cache that inactive snapshot without retaining a scene or moving focus.\n\n## Files, state and shutdown\n\n`FileSource` reads files; `FileActions` holds the file menu's native operations\n(Copy path, Reveal), and the viewer never writes a file. Both apps read any CAD\nfile on the machine by its absolute path, through the viewer server: what a view\nlists and searches is the server's, and a request for a file that is not there is a\n`cad-file-missing` error (`isMissingFileError`).\n\nFileViewer opens a file's document when the file is shown, and again only on `reload`: a\nrenderer follows its file itself. A CAD renderer (its document from\n`prepareWorkspaceEntry`) reads the live catalog entry, so a content change — or a write\nthat briefly empties the file — loads the next revision behind the model on screen, under\n\"Updating model…\" (a drawing's: \"Updating drawing…\"). Once a model has been shown, a\nrebuild is an update, never the loading screen.\n\nState remains controlled through FileViewer props, and everything the viewer keeps\nis the tab's (`@text-to-cad/ui/tab-store`): one record per tab, `{ version, settings,\nfiles }` (`version` is `TAB_RECORD_VERSION`, 2; a record another version wrote reads as\nthe defaults), thrown out with the tab and kept across a reload. The host supplies where it\nlives through one adapter, `TabRecordStorage` — a synchronous read and write of the\nwhole record: the web over `sessionStorage`, the CAD app over memory\n(`memoryTabRecord`) — and the package owns the record's shape, version and normalization. `settings` is\n`{ toolStack, appearance, library }`: tab-wide (the file sidebar's and tool stack's layout, the\nappearance, the home's layout), and what every renderer reads as its preferences;\n`files` holds the view of the file on screen, under `[absolute path, renderer id]`, and no\nother: a write keeps the newest alone (`TAB_FILE_LIMIT`), and `CadViewer` drops the view\nof a file it leaves — for another file or for the home — once the\ndeparting renderer's last write has landed (`files.retain(path | null)`; `null`, the home,\ndrops every view). The store reads and writes a view (`files.read`, `write`, `remove`),\nlists them all (`all()`) and merges a view's changes (`merge`). A reload of the tab shows\nthe same file, so it brings that view back; a file opened again after leaving it starts\nat the defaults. In the CAD app there is one record per view: a view the host\ncreates again (its frame re-created) starts afresh, since nothing names a view across its\nframes. A view is `{ camera, display, playback, renderer }` (`kit/shell/fileView.js`):\nthe camera is restored in place of the open-time fit, the display settings with their\nClip and Explode, the playback settings (Orbit's speed, Autoplay, and the chosen speed and\nloop), and the renderer's own slices each behind the\nsignature it was written against — a slice that no longer fits the file on screen is\ndropped, the camera, the display and the playback never. Not in it, and started afresh\non every open: the tool in hand, the selection, measurements, ink, Preview, a\nroutine's time and Quick Edit's note. Apps merge\nwhat a view changed into the store (`files.merge`); a stale view must not overwrite\nanother view's entries. Material appearance is source-owned and read-only. Live\nselection and scene ownership belong to the mounted view.\n\nA mounted view writes its view shortly after each change (the camera on every move,\ndebounced) and once more when it unmounts, and nothing after that; the store writes\nthrough synchronously, so what the tab last saw is what a reload restores. Nothing\nsaves document content or promises an asynchronous operation will finish during page\nexit. Web owns pagehide\n(which unmounts the app), focus, visibility, history and reload under a new server. Desktop\nowns window/runtime lifecycle and IPC.\n\nThe dependency checker enforces host boundaries, including worker source. The\nbrowser harness mounts real renderers with explicit fake hosts; app tests cover\nnative/clipboard delivery and multiple-view state merges. Warm-cache regression\ntests remain required for resource changes.\n\n## Keyboard scope\n\nViewer shortcuts consume only events from their own viewer, or from the page\nbackground after a pointer press in it, so another viewer or the composer never\nreceives Escape, copy or orbit keys on its behalf\n([settings-ui.md](settings-ui.md#keyboard)). Annotate's surface reads the host's\nkeyboard `platform` (`environment.platform`) for its undo and redo keys.\n\n## Host chrome slots\n\nFileViewer draws ONE navbar, the same in every app, over every file and over a host's home.\nOver the home it has only the logo and its menu (no Back to files), Back and Forward where\nthe host hands a `history`, and the host's update and Full size, each where it stands over\na file; the home's search, layout switch and Open stay\nin its Recent Files row, and its links under its title. Left: the text-to-cad \"C\" logo,\nwhich opens the app menu (`AppMenu`, `file-viewer/navigation/AppMenu.jsx`), then Back and\nForward where the host hands a `history`, then the open file's name and its ⋯ menu. The\nlogo, Back and Forward, the name and the ⋯ share one style (`NAV_ITEM_CLASS`):\ntransparent at rest, the same accent background while the pointer is on them or their menu\nis open, a pointer cursor, and no tooltip. The name opens the explorer where the host's\nfiles can be browsed (`files.list` and `files.search`), and is plain text where they\ncannot; the ⋯ offers Copy path and Reveal in Finder / Show in Explorer / Show in file\nmanager, each where `fileActions` can do it, and is absent where it can do none (no\nright-click on the name). Right, only what a host or a file adds: the host's update button\nfirst (`update`, while its install is behind), the renderer's navigation actions, the\nhost's Full size where it shows the view small\n(`fullSize`, the inline card's way to full size, over the home too), and last the box the\nrenderer draws its own controls into (over a file only). The navbar holds no settings.\n\nThe app menu is FileViewer's, built from the host's `navigation.home`, `links` and\n`environment.platform` and the person's `FileViewerProps.appSettings` (which `CadViewer`\npasses on), the same over a file and over the home. It is a dropdown on the floating surface.\nFirst **Back to files**, where the host has a home; then\nthe host's on/off settings (`appSettings`, below) as checkbox items, checked at the right,\nwhich a press turns without closing the menu; then, where the host has a tracker\n(`links.issues`), **Send feedback**, a link to a new issue titled \"Feedback: \"\n(`feedbackUrl`, `NavbarLinks.jsx`), for the person to finish, naming the version and\n`environment.platform` — it has no label: the project has none for feedback, and what is\nsaid may be a bug, a request or a question — then **GitHub** and **Discord**; and last, in\ngray, \"v<version> · Made by @<handle>\": the version links its release notes\n(`links.release`) and `MadeBy` the host's X account. The version is the release's own build's\nas it is (`v0.7.15`), and any other build's with its id (`v0.7.15-dev.b80844940`, the commit\nit was built from, `-dirty` with uncommitted changes): `viewerLinks({ version, build })`,\nwhere each app's Vite config names the build through `@text-to-cad/ui/build-id`, and a new\nissue names it the same way. The home also has GitHub, which says the project is open\nsource, Discord and X as icon links under its wordmark (`HomeLinks`), in that order.\n\nAn alert card's Report Issue opens a new issue too,\ntitled \"Issue: \" and labelled `bug`, filled in from the card (`kit/status/reportIssue.js`): its\ntitle, message and failure, the file's name, the version and platform, then its Details, cut from\ntheir end to keep the address, title and labels included, under `ISSUE_URL_MAX`. No path of the\nmachine goes with it: the file's path is its name wherever the card writes it, and a home\ndirectory in anything else is `~/`. A label is a suggestion: GitHub applies a URL's labels only\nfor someone with triage access to the repository and drops them for everyone else. A link opens\nthe ordinary way unless the host supplies\n`links.open` (a page in a sandboxed frame hands it to its host), which every link of the\nmenu follows. The colour scheme is the menu's Theme row (`colorScheme`, a `CadViewer`\nprop), over a file and on the home alike; no host has a light/dark button of its own, and\nDisplay holds no appearance control. `language` (`{ locale, inferred, choice, onChoiceChange }`, a `CadViewer` prop) is the person's\nlanguage for the app menu's Language row: `choice` null is Automatic, `inferred` what Automatic\nmeans; both apps keep the choice in the person's settings (`/__cad/language`, the `language`\nsection of `settings.json`), so it holds for every CAD view. `appSettings` (`{ id, section, label, checked, disabled?, onCheckedChange }[]`,\na `CadViewer` prop) are the host's own on/off settings: checkbox items of the app menu, in the\norder they come (Share anonymous usage data, in both apps), the same in the\nlogo's menu and the home's; nothing of them reaches a renderer, and Display holds none. The\nhost owns what each one does and where it is kept.\n`@text-to-cad/ui/consent` is the usage stats toggle both apps share:\n`useAnalyticsConsent(consent)`, which turns the host's consent call into its `appSettings` row\n(Share anonymous usage data). Nothing asks: cadgen's telemetry is on by default once a `cadgen` command\nhas said so. The host supplies the call and where the answer is kept.\n`@text-to-cad/ui/update` is the update button both apps share, from cadgen's version check:\n`UpdateButton`, the blue button a host hands the viewer as its `update` (`CadViewerProps.update`,\nfirst among the navbar's right-hand controls, an icon, over every file and over the home), which opens its card in a popover, and `useUpdateNotice(call, initial)`, which\nstarts on the notice the host already has (a CAD app's launch carries it; the web page reads it with\nits server's description), so the button draws with the page, and reads the host's call again when\nthe page regains focus. Nothing it does is an answer the server keeps: the button stays while the\ninstall is behind and goes once the update lands. The card sends its prompt\nto the agent's chat through the host's `send`, where the host can, and otherwise copies it through\n`copy`, its button then Copy prompt; the prompt has a copy icon in its top-right corner either way; the host decides which it supplies. Its\nManual installation link, the notice's `instructions`, opens through the host's `onLink`. The shell\nhandles placement and hides the toolbar in\nPreview; the host owns callbacks and preferences. None of these imply platform\ndetection or move application-specific release/network behavior into shared UI.\n\nThe explorer is a popover under the file's name (`FolderExplorer.jsx`;\n[FileViewer](file-viewer.md#the-navbar-and-the-explorer)): it floats over the view and\nnever resizes it.\n\nA file that will not open leaves the navbar over a page of the host's\n(`presentation.error`). `CadViewer`'s says \"File does not exist\" and the file's path\nwhen it is missing (`isMissingFileError`), and \"Could not open that file\" and the\nreason otherwise, each with a Go home button where the host has a home.\n\nThe viewport's edges and corners are the shell's, never the host's: the tool strip at the\ntop centre, ending in the view's own controls (Display and Preview, a 3D view's), the tool in\nhand's sub-toolbar under it, the file sidebar down the left edge, Quick Edit at the\ntop-right, and the view cube at the bottom-right. A host's one notice goes through the shell too: `notice` is drawn at the top-right once the\nfile is on screen, never while it loads or after it failed to, with Quick Edit stacked under\nit while it stands; the home never shows it. Neither app has one now.\nWhat Quick Edit offers follows the subscribed destination capability and\nthe ports, never an app name: Copy Prompt always, Queue for a composer\ndestination, Send where the prompt port has `send`. Native clipboard effects\nremain in the host implementation.\n\nA STEP's selection copy (a chip's click, the copy shortcut) and Annotate's\ncopy shortcut (the view with its annotations) use `ClipboardPort` directly, including on\ndesktop: the references as text, the view with its ink as a PNG through\n`writeImage`. Their copy shortcut follows the same path. A double-click copies\nnothing: on a face or edge it turns the view square to it, on a component or\nsubassembly it isolates it. The host supplies\n`environment.platform` for the ⌘C / Ctrl+C hint; the web host derives that field\nfrom its browser environment.\n\nAn alert card's Details have a copy icon in their box's top-right corner\n(`kit/status/ViewerAlertCard.jsx`): it copies the whole of them, however far they scroll,\nthrough `ClipboardPort.writeText`, and shows a tick for a moment. A host whose clipboard\nrefuses the write gets a line saying so under the box, where the text stays to select; a\nview with no host clipboard gets no icon. Each host makes its port copy wherever it can: the\nweb falls back to the page's copy command when the browser refuses the asynchronous\nclipboard, and so does the CAD app when its host grants its frame no clipboard.\n\nA copied reference names its file by its absolute path (`<path>#<selector>`), and so\ndoes a copied Quick Edit: every view spells a file the same way, so a reference pasted\ninto a chat or a tool says exactly which file it means.\n\nNothing of the viewer sits at the bottom centre. A host whose own control floats over\nthat edge (a chat's composer) sets `--cad-host-bottom-inset` on an ancestor to the height it\ncovers: the model library scrolls its last rows clear of it. A host whose own control\nfloats over the view's bottom-right corner sets `--cad-viewport-corner-bottom` to the room\nit takes from the bottom edge, and the view cube sits that much higher. To choose the\nopposite corner instead, set `--cad-viewport-corner-left: 2px` and\n`--cad-viewport-corner-right: auto`; their defaults are `auto` and the cube's right inset.\nCodex's tab presentation uses the left corner across sidebar, thread and file views.\nThe shared viewer consumes these layout values without identifying the host.\n\nA renderer's update status is its own: the CAD renderers show it centred under the tool\nstrip and its sub-toolbar. The navbar carries none.\n\n<!-- File: skills/bambu-labs/agents/openai.yaml (Tokens: ~60 | Category: anthropic-skill) -->\ninterface:\n  display_name: \"Bambu Labs\"\n  short_description: \"Send prints to Bambu printers via Bambu Connect\"\n  default_prompt: \"Use $bambu-labs to send my sliced print to my Bambu Lab printer through Bambu Connect.\"\n\n<!-- File: skills/bambu-labs/SKILL.md (Tokens: ~943 | Category: anthropic-skill) -->\n---\nname: bambu-labs\ndescription: Send prints to Bambu Lab printers through Bambu Connect, Bambu Lab's official app for printing from other software, or Bambu Studio. Use when the user wants to print a sliced `.gcode.3mf`, a Bambu `.gcode` or an unsliced model on a Bambu Lab printer, over Bambu Cloud or LAN. The agent opens the file in the app; the user picks the printer and starts the print there.\nlicense: MIT\n---\n\n# Bambu Labs\n\nProvenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).\nUse the installed local skill files as the runtime source of truth; the\nrepository link is only for provenance and release review.\n\nBambu Lab printers take print jobs from other software only through Bambu\nConnect, Bambu Lab's desktop app for third-party tools\n(https://wiki.bambulab.com/en/software/third-party-integration). This skill\nopens a file in Bambu Connect, or an unsliced model in Bambu Studio; the user\npicks the printer and starts the print there. The agent never controls a\nprinter and never asks for access codes.\n\n## Requirements\n\nBambu Connect (Windows 10 or later, macOS 13 or later), signed in to the user's\nBambu Lab account, with the printer on that account or reachable in LAN mode\n(https://wiki.bambulab.com/en/software/bambu-connect). On Linux, where Bambu\nConnect is still in development, use Bambu Studio.\n\n## Choose the handoff\n\n| What the user has | Handoff |\n| --- | --- |\n| A sliced `.gcode.3mf`, from `$gcode` or Bambu Studio's or OrcaSlicer's \"Export plate sliced file\" | Open it in Bambu Connect |\n| A plain `.gcode` sliced with this printer's Bambu profile | Open it in Bambu Connect; if Bambu Connect refuses it, slice the model in Bambu Studio instead |\n| A model with no slice: `.3mf`, `.stl` or `.step` | Open it in Bambu Studio, which slices it and sends it to the printer itself |\n\n## Open a file in Bambu Connect\n\nBambu Connect imports a file from a `bambu-connect://import-file` link with\nthree parameters: `path`, the file's absolute path, and `name`, the name to\nshow, each percent-encoded the way JavaScript's `encodeURIComponent` does it\n(Python: `urllib.parse.quote(value, safe=\"\")`); and `version=1.0.0`. For\n`/Users/me/prints/bracket.gcode.3mf`:\n\n```text\nbambu-connect://import-file?path=%2FUsers%2Fme%2Fprints%2Fbracket.gcode.3mf&name=bracket&version=1.0.0\n```\n\nOpen the link with `open \"<link>\"` on macOS, or `Start-Process \"<link>\"` in\nPowerShell on Windows. Bambu Connect comes forward with the file loaded.\n\n## Open a model in Bambu Studio\n\nOn macOS run `open -a BambuStudio <file>` (some installs name the app\n`Bambu Studio`). On Windows open the file from Bambu Studio's File menu, or run\n`Start-Process <file>` when Bambu Studio is the file's default app. On Linux run\n`bambu-studio <file>`, or open it from the AppImage or Flatpak.\n\n## Finish in the app\n\nBefore the user presses Print, tell them what to check: the printer, plate\ntype, nozzle, filament and AMS mapping, a clear build plate, and someone nearby\nfor the first layer. Never report a print as started: the agent can't see the\nprinter, and the app shows the job and its progress.\n\n## Out of scope\n\nStarting, pausing or cancelling prints without the user, and reading printer\nstatus: Bambu Lab's firmware takes those from other software only through Bambu\nConnect, or in Developer Mode, which is LAN-only and disconnects the printer\nfrom Bambu Cloud. For slicing, use `$gcode` or Bambu Studio.\n\n<!-- File: skills/cad/agents/openai.yaml (Tokens: ~59 | Category: anthropic-skill) -->\ninterface:\n  display_name: \"CAD\"\n  short_description: \"Generate and validate CAD artifacts.\"\n  default_prompt: \"Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, then launch CAD Viewer and return live links to the created or updated artifacts.\"\n\n<!-- File: skills/cad/references/animation-deformation.md (Tokens: ~888 | Category: anthropic-skill) -->\n# Tube deformation and morph export\n\nRead this reference for flexible swept bodies in an embedded animation, or for\nexporting that deformation into GLB. The [motion reference](kinematics.md)\ncovers ordinary joints, poses, clip declarations and video rendering.\n\n## Deforming an existing tube\n\nCall `.deformTube()` on an animation handle for a continuous swept STEP body:\n\n```javascript\n// Inside update(t, m), with restPath and posedPath defined for this frame:\nm.get(\"tendon\").deformTube({\n  rest: restPath,\n  path: posedPath,\n  twistDeg: 0,\n  maxSegmentLength: 1,\n});\n```\n\nBoth paths are `{normal, segments}` in assembly coordinates. `normal` is a\nrequired transverse frame seed on each path. Segments must connect tangentially:\n\n| Segment | Fields |\n| --- | --- |\n| Line | `{kind: \"line\", start, end}` |\n| Arc | `{kind: \"arc\", center, axis, start, sweepDeg}` |\n| Cubic Bezier | `{kind: \"bezier\", points: [p0, p1, p2, p3]}` |\n\nPositions are vec3 millimetres; angles are degrees. Normalized arc length maps\nthe rest path to the posed path. Check tendon length, bend radius and collisions\nseparately: deformation does not solve those constraints. `twistDeg` rotates\nauthored cross-sections; it does not calculate spool payout.\n\n`maxSegmentLength` controls one-time longitudinal mesh refinement in millimetres\n(default `1`, minimum `0.05`). The rest mesh, normals and topology remain\ncontinuous, and shared source buffers remain immutable. Omitting deformation in\na later frame restores the rest shape.\n\nOptional `braid: {pitch: 0.8, depth: 0.02, strands: 8}` adds procedural fiber\ncolor and normal relief over the swept core. Pitch and depth are millimetres;\nthe strand count must be even. This is a surface finish, not additional CAD or\ncollision geometry.\n\n## Exporting deformation to GLB\n\nAnimated GLB refuses tube deformation by default. Request morph targets explicitly:\n\n```bash\ncadgen glb build STEP/hand.step GLB/animated/hand.glb \\\n  --animation '{\"clip\": \"fist\", \"fps\": 24, \"seconds\": 6,\n                \"deform\": \"morph\", \"deformTolerance\": 1.0}'\n```\n\nEach deforming node gets per-vertex position and normal deltas against one base\nmesh, plus a weights channel. Interpolation between baked poses can differ from\nthe procedural deformation. The exporter fits targets per occurrence against\n`deformTolerance` (millimetres, default `1.0`, range `0.01`..`10`), checking a\ngrid four times finer than `fps`, at least 96 Hz. This sampled check is not a\ncontinuous-time error guarantee. The export summary reports `targets`, `bytes`,\n`runtimeBytes`, `deviationMm` and `fitGridHz`.\n\nThe exporter refuses a bake exceeding 512 MiB of estimated morph playback\nmemory: 32 bytes per vertex per target, or 16 without normals. To reduce it,\nrelax `deformTolerance`, shorten `seconds`, increase `--mesh-tolerance`, or\nincrease the clip's `maxSegmentLength`, as the required fidelity permits.\n\nLimitations:\n\n- Procedural braid shading is not exported. The GLB retains the tube's geometry\n  and motion with a smooth surface.\n- Changing a tube's `rest` path during the clip is refused because morph targets\n  share one base mesh. Use `.rotate()` or `.translate()` for rigid motion.\n- A tube held in a constant deformed pose exports in that pose without morph\n  targets. `\"deform\": \"rest\"` instead explicitly freezes tubes at rest and warns;\n  use it only when that is the intended output.\n\nThe CAD Viewer plays baked morph animation from GLB, but the original clip's\nprocedural controls remain in the STEP sidecar. Render a video when the output\nneeds effects GLB cannot carry; see [motion review](kinematics.md#reviewing-motion).\n\n<!-- File: skills/cad/references/build123d-modeling.md (Tokens: ~2353 | Category: anthropic-skill) -->\n# build123d modeling patterns\n\nRead this file when constructing or repairing native CAD geometry. Model scripts\nreturn build123d shapes; decorators declare the output files. See the\n[model contract](step-generation.md) for execution and composition.\n\n## Construction choices\n\nChoose a construction that expresses the controlling dimensions directly and\npreserves the intended geometry. Builder contexts and algebraic modeling are\nboth valid; profile operations, solid features and surface construction suit\ndifferent shapes. The examples and remedies below are options, not a required\nfeature sequence.\n\nUse meaningful parameters and functional datums. Keep separately manufactured\nor moving parts identifiable in an assembly; fuse bodies where the design calls\nfor one continuous part. Prefer solids for physical parts, but do not close or\nthicken surfaces when the user requested surface geometry.\n\nOperation order depends on the design. Applying finishing features late often\nmakes selectors simpler; constructing them in a profile can be more robust for\nsome outlines. Through-cut tools should span the intended material; choose any\nextra extent relative to the geometry and nearby features, not a fixed distance.\n\n## Selection and topology\n\nAn **occurrence** is a placed assembly node. Its geometry contains bodies,\nfaces, edges and vertices. For inspection, keep the canonical `ref` and\n`occurrence_ref`; see [inspection](inspection-and-validation.md) for enumeration.\nNumeric selectors belong to one saved revision.\n\nFor construction, select by the feature's geometry or datum where practical:\nnormal, axis, plane, position or curve type. Re-evaluate selections after an\noperation that changes the relevant topology. Select a fillet's or chamfer's\nedges from the solid it rounds, after that solid's last operation: an\noperation returns new native topology wherever it changed the solid, so an\nedge held from before it may not be the solid's own. A fillet silently skips\nsuch an edge, and fails when none is left; a chamfer fails on it. Shape\nequality is build123d's own and compares native handles, so\n`edge in solid.edges()` is true only for the solid's own edge.\n\n## Labels and assemblies\n\nUse concise, meaningful native labels on exported parts and assembly occurrences,\nincluding repeated placements such as `m3_screw:front_left`:\n\n```python\n# Inside an assembly model, after constructing and placing its parts:\nbase.label = \"base\"\nlid.label = \"lid\"\nassembly = bd.Compound(children=[base, lid], label=\"electronics_enclosure\")\n```\n\nLabels describe role or placement; they need not repeat topology categories.\nA feature fused into or cut from a body does not retain a separate occurrence\nlabel. Keep its intent in parameters, construction datums and geometric checks.\nDo not promise persistent face/edge names through STEP export.\n\nSee [positioning](positioning.md) for assembly datums, joints, explicit transforms\nand checks of saved mating relationships. Source joints position geometry;\n[kinematics](kinematics.md) separately declares motion for the viewer.\n\n## Colour and finish\n\nNative `Color` channels are linear RGB. Use `cadgen.srgb` for a colour specified\nas a display hex value:\n\n```python\nfrom cadgen import srgb\n\nbody.color = srgb(\"#2E3742\")\nglass.color = srgb(\"#38414D\", 0.42)\n```\n\nSet native colour on leaf occurrences: a colour on a group compound does not\npropagate to its leaves in the render tree. Named material assignments can target\na group and expand to its leaves:\n\n```python\n@step(materials={\n    \"definitions\": {\n        \"cast\": {\"name\": \"As-cast steel\", \"roughness\": 0.85, \"metalness\": 0.2},\n        \"ground\": {\"name\": \"Ground steel\", \"roughness\": 0.25, \"metalness\": 0.9},\n    },\n    \"assignments\": [\n        {\"targets\": [\"#housing\"], \"material\": \"cast\"},\n        {\"targets\": [\"#journal\"], \"material\": \"ground\"},\n    ],\n})\ndef gearbox():\n    ...\n```\n\nMaterial keys are optional `name`, `baseColor`, `roughness`, `metalness`,\n`clearcoat`, `clearcoatRoughness`, and `opacity`. Numeric channels are finite\n0..1 values; `baseColor` is `#RRGGBB`. STEP's sidecar carries these finishes;\n`baseColor` does not change STEP colours or geometry bytes. Finishes inherit\nthrough cached child composition and are consumed by Render and GLB export.\nNormal CAD display keeps colour/opacity with workbench shading. Dynamically\nsetting `cad_material` is unsupported.\n\n## Placement and frame pitfalls\n\n- **Rotation frame:** do not infer a local rotation axis from Euler-angle\n  spelling. Check the transformed basis for a non-global `Plane`; explicit\n  direction vectors or an axis-angle construction can make the intended frame\n  clearer. A valid loft can still join incorrectly oriented sections.\n- **Primitive alignment:** `align=None` preserves the primitive's raw datum;\n  it does not mean centered. For example, a cylinder is based at Z=0 while a\n  box starts at a corner. Set alignment deliberately when placement depends on it.\n- **Absolute versus relative placement:** `.located(loc)` replaces location;\n  `.moved(loc)` and `Location * shape` compose it. Applying `.located()` after\n  a rotation can therefore discard the orientation. For placed child models,\n  use `.moved()` or multiplication to preserve shared geometry; `.located()`\n  copies it and loses the child link. Absolute placement remains a native API\n  option when replacement and copying are actually intended.\n\n```python\n# A local box, rotated before placement:\nbox = bd.Solid.make_box(1, 1, 1)\nrotated = box.rotate(bd.Axis.Z, 90)\nplaced = bd.Location((5, 0, 0)) * rotated  # retains the rotation\n```\n\nIn build123d 0.11.1, a moved assembly compound's `intersect()` can traverse\nunmoved `.children` despite `.wrapped` carrying the correct placement. Extract\nplaced `.solids()` or operate on the placed OCCT shapes when affected.\n`cadgen.geometry.overlap_volume` takes individual solids and queries private\ncopies of their placed geometry. Check the actual pose when diagnosing an\nunexpectedly constant interference result.\n\n## Loft and sampled-profile pitfalls\n\nFor sampled sections, keep feature order, edge correspondence and orientation\nconsistent across stations. Twisting or rippling can be present in valid\ngeometry, so check the intended sections and review the surface.\n\n- Corresponding feature rails or consistent samples per band can prevent a\n  feature from drifting between sections. Equal sample counts alone do not\n  guarantee a correct loft.\n- Independently easing every interval to zero slope can introduce unintended\n  flat spots. Choose interpolation for the desired continuity. Smooth measured\n  noise only within the task's geometric tolerance.\n- A disconnected or self-crossing section can produce misleading downstream\n  loft failures. Inspect section wires as well as face validity. Increasing\n  prefixes or adjacent section pairs can localize a failing region.\n- A ruled loft can help diagnose a smooth-loft failure, but changes the surface;\n  use it as a replacement only if its faceting and continuity meet the design.\n- For fields built by blending sampled component profiles, a component ending\n  inside its neighbor may leave a steep wall. Inspect the blend function and\n  continuity before increasing sample density. Adjust component overlap or the\n  blend only if it preserves the required boundary.\n\n## Boolean and finishing pitfalls\n\nKernel behavior depends on topology, tolerances and the installed build123d/OCP\nversion. Diagnose the failing operation before changing the construction.\n\n- **Many tools:** batch independent cuts when that avoids repeatedly rebuilding\n  the same intersection network. Overlapping tools may need staged operations;\n  sequential cuts are legitimate when later features depend on earlier ones.\n  Restrict excessive tool extents when they create unnecessary intersections.\n- **Large spline surfaces:** even batched cuts can be expensive. Localize the\n  expensive operation with timing or a stack sample, then consider simpler\n  surfaces, smaller tool regions or a different construction of the same feature.\n  A groove's screen size is not a reason to remove it from the CAD model.\n- **Near tangency or coincident boundaries:** inspect the removed/added region\n  and solid count, not only a successful return. A direct profile construction\n  may avoid an unstable Boolean intersection, when it describes the same shape.\n- **Fillets and chamfers:** check selected edges and available local space.\n  Smaller radii, different feature ordering, grouped selections or a profile\n  bevel are possible remedies, subject to the required dimensions. Do not\n  silently reduce or omit a specified feature through a retry ladder.\n- **Tangent chains or complex outlines:** some kernel versions fail or crash\n  during finishing operations. Isolate a reproducible case; a profile bevel or\n  separately constructed transition is a workaround, not a ban on 3D fillets.\n- **2D algebra:** inspect intermediate types. A `ShapeList` participates in\n  Python list operations, which may concatenate instead of fusing geometry.\n  Check wire winding/face normals before extrusion, especially after mirroring\n  sampled points. Confirm an intersection is nonempty before using it as a cut.\n- **Dense periodic splines:** build123d 0.10/OCP 7.9 have shown failures in taper,\n  inward offset and coincident-face fusion for some densely sampled profiles.\n  If reproduced, simplify the representation or construct the offset/transition\n  explicitly and verify its distance, continuity and topology. Numerical offsets\n  are an option, not a universal replacement for kernel offsets.\n\nPreserve the requested geometry while repairing it. If an exact feature cannot\nbe built, report the limitation or seek a design decision rather than quietly\nsubstituting an approximation.\n\n## Validity and visual artifacts\n\nTopology validity does not prove positive orientation, the requested shape, or\nsuitability for a later Boolean. Check signed volume per intended solid;\naggregate volumes can hide inverted members. Open shells are legitimate when\nsurfaces were requested.\n\nUse `cadgen.geometry.topology_errors`, `boundary_edges` and, when relevant,\n`self_intersections` on saved geometry. During a failing construction, check\nintermediates around the suspect operation. A validity gate inside a model\nbody — a retry ladder that accepts a fillet only when the result is sound, a\nstage check on a casting — uses build123d's `shape.is_valid` and\n`cadgen.geometry.is_sound` (the `BRepAlgoAPI_Check` verdict, which also\nidentifies Boolean-suitability issues such as tiny edges). A check costs\nkernel time, so gate where a failure is plausible rather than after every\nsimple operation. Any repair must preserve the dimensions being checked. See\n[inspection](inspection-and-validation.md).\n\nA periodic cylinder or revolved face has a seam edge that may appear in CAD\nlinework. Use shaded display or another camera to distinguish a display seam\nfrom a crack. Do not rotate a finished, datum-constrained part solely to hide\nits seam.\n\nFor further failure diagnosis and before/after checks, see [repair loop](repair-loop.md).\n\n<!-- File: skills/cad/references/cad-brief.md (Tokens: ~387 | Category: anthropic-skill) -->\n# Interpreting a CAD request\n\nUse this reference when modeling from prose, reference images or technical\ndrawings. Record only the dimensions, interfaces, outputs and assumptions needed\nto construct and check the requested geometry. A simple part may need a sentence;\na complex assembly may benefit from a dimension/interface checklist. There is\nno required brief format, and direct inspection or export needs no modeling brief.\n\nClarify missing or conflicting information when it materially affects the result,\nespecially a mating interface or required scale. Otherwise proceed with explicit\nassumptions. Do not ask the user to fill out a template.\n\n## Reference images\n\n- Distinguish reproducing a part from using an image as design inspiration.\n- Establish scale from a supplied dimension or known reference when available.\n  Unscaled images can guide proportions; they do not establish fit dimensions.\n- Record inferred dimensions as assumptions. Ask for scale when fit depends on it.\n- For reproduction, compare a snapshot from a similar viewpoint with the image.\n\n## Technical drawings\n\n- Read units, projection convention, revision and notes; identify each view and\n  section before mapping dimensions to model axes.\n- Prefer explicit dimensions over image proportions. Resolve conflicting\n  dimensioned sources rather than silently choosing one.\n- Preserve multiplicity (`4X`), `TYP.`, threads, counterbores, countersinks and\n  internal depths shown in sections. Track the requirements for later checks.\n- Derive unspecified geometry from constraints where possible. Treat dimensions\n  estimated from a drawing image as assumptions, never as verified callouts.\n- Verify the specified dimensions against saved geometry using Python\n  measurements, or explicitly report those not verified. See\n  [inspection and validation](inspection-and-validation.md).\n\nBefore modeling, identify the relevant source/output paths, units, coordinate\nconvention and functional interfaces. Choose construction and validation methods\nthat make the controlling dimensions easy to express and check.\n\n<!-- File: skills/cad/references/inspection-and-validation.md (Tokens: ~2685 | Category: anthropic-skill) -->\n# Inspecting and checking saved CAD in Python\n\nUse small Python scripts against the saved STEP/STP. `cadgen step inspect`\nand `cadgen.step.inspect` have been removed. Choose the relevant entities,\nmeasurements and acceptance criteria in the script; there is no replacement\ninspection command or universal validation report.\n\nPut exploratory scripts in the CAD project's ignored `tmp/`, for example\n`tmp/check_clearance.py`; system `/tmp/` is also fine for disposable work.\nKeep reusable design checks in `checks/` (for example `checks/clearance.py`),\nor use the project's existing test location. Commit those checks so they can\nbe rerun after a model changes. Create the directory only when needed.\nKeep checks outside `src/` model directories and raw format folders such as\n`STEP/`. Examples below use paths relative to the CAD project root; run from\nthere, or anchor input paths explicitly when a script must run elsewhere.\n\nReport what was measured, the units, the threshold and the selected geometry.\nA failed computation is not a passing check, and an untested relationship has\nno verdict. Run the model first when you want to check newly authored geometry.\n\n## Opening and selecting geometry\n\n```python\nfrom cadgen import read_step, read_scene\n\nshape = read_step(\"STEP/bracket.step\")  # native build123d geometry\nscene = read_scene(\"STEP/assembly.step\")  # hierarchy and exact selector refs\n\nprint(scene.document_hash)\nfor occurrence in scene.leaves():\n    print(occurrence.ref, occurrence.label, occurrence.prototype_id)\n\n(housing,) = scene.resolve(\"#housing\")\nprint(housing.ref, housing.kind)\nsolid_or_compound = housing.shape()\nfor face in housing.entities(\"face\"):\n    print(face.ref, face.shape().area)\n```\n\n`read_step` keeps its existing name. `read_scene` replaces the old\n`load_step_scene` helper. Inside a model, the document either reads is a build\ninput, as every file a build reads is. Paths expand `~` and resolve relative to the working directory.\n\n- `scene.roots` and `occurrence.children` are tuples. `scene.leaves()` yields\n  geometry occurrences, including repeated copies. A leaf can hold multiple\n  solids; the reader does not infer manufactured parts.\n- `scene.resolve(ref)` returns a tuple of selections, one per ref in the line\n  (see [Reference syntax](#reference-syntax)). A selection has `ref`, `kind`,\n  `occurrence_ref`, `shape()` and `entities(kind)`. Kinds for enumeration are\n  `\"shape\"`, `\"face\"`, `\"edge\"`, `\"vertex\"`. Shape entities (`sN`) are solids,\n  or shells when no solids exist, or the leaf geometry itself.\n- Every `shape()` is caller-owned exact build123d geometry in the document's\n  world coordinates, including ancestor placements. A group produces an\n  unfused compound. Modifying the result cannot change the scene or another\n  selection. Keep the returned shape when making several queries about it.\n- `prototype_id` identifies shared geometry within this scene, allowing a\n  script to avoid repeating a suitable body-local check. It is not a part\n  number or an identity across revisions. Numeric self-intersection tests can\n  behave differently at different placements; state which placements ran.\n- A scene is bound to one document hash. Replacing the file does not change an\n  already opened scene. Reopen it to inspect the new revision. Selector IDs\n  are revision-scoped, not persistent feature names.\n- Opening reuses the canonical BREP cache, decoding prototypes on demand.\n  No display surfaces, tessellation, source discovery or source execution are\n  needed. Cached inventory and occurrence lookup defer the build123d import\n  until native geometry is requested. Keep the scene open: repeated reads\n  still hash the document bytes, and fresh Python processes pay import costs.\n- The scene describes the saved placement. It does not evaluate sidecar\n  kinematics or animation poses.\n\n### Reference syntax\n\n```text\n#o1.2          occurrence or subassembly\n#o1.2.f7       canonical face 7 on that leaf occurrence\n#housing.e3   canonical edge 3 on the occurrence labelled housing\n#f7           face 7, only when the scene has exactly one leaf\npart.step#o1  a file-prefixed ref matching the opened STEP\npart.step#o1.2.f7,o1.2.f8  several refs, as the viewer copies a multi-selection\n#o1.2.f7,f8   a bare entity after an occurrence or label belongs to it\n```\n\n`resolve()` takes a whole copied line, one ref or a comma list, and returns a\ntuple with one selection per ref, in order; any ref that does not resolve\nraises. Unpack a single ref: `(face,) = scene.resolve(\"#o1.2.f7\")`.\nTo enumerate a subassembly's faces, resolve it, `(group,) = scene.resolve(\"#group\")`,\nand call `group.entities(\"face\")`; a group has no independent\nface ordinals. Enumeration preserves canonical occurrence IDs and never\nmerges coincident instances. Face and edge IDs agree with the viewer; vertex\nIDs enumerate native STEP vertices (the viewer currently exposes no vertex\ntable).\n\nLabels use letters, digits, `_` and `:`, and cannot start with a digit or\ncollide with numeric reference syntax. Other labels remain accessible through\nnumeric IDs. Duplicate labels receive numbered aliases in occurrence order;\nresolving the ambiguous bare label raises and lists candidates. No fuzzy\nmatching or choosing the first match.\n\nA copied reference's file prefix is the file's absolute path, with the file's real\nname and extension (in quotes when it holds a space or `#`). `resolve()` reads the\nprefix as a path, with `~` expanded, links followed and a relative one read from the\nworking directory, and it must name the opened document.\nA relative prefix that names no file from where you run still matches the end of\nthe document's path. A prefix naming a different document is rejected. Do not\nguess between ambiguous files.\n\n## Measurements\n\nMost measurements already have native build123d interfaces:\n\n```python\n# p, q: chosen points; u, v, direction, normal: explicit vectors\nlength = (q - p).length\nsigned_offset = (q - p).dot(direction.normalized())\nangle_degrees = u.get_angle(v)\nsigned_angle_degrees = u.get_signed_angle(v, normal)\n\n# Select the actual geometric feature that defines the dimension.\ncenter = circular_edge.arc_center\nradius = circular_edge.radius\ndiameter = 2 * radius\nlength = edge.length\narea = face.area\nvolume = solid.volume\nsize = shape.bounding_box().size\n```\n\nA bounding box dimension is an envelope, not a hole diameter or wall\nthickness. A surface centroid is not necessarily a datum. Use analytic\ncenters, axes and selected points when those define the requested dimension.\nThe default `bounding_box()` searches for tight bounds and can be expensive\non curved geometry. Use `bounding_box(optimal=False)` for a fast conservative\nenvelope when filtering candidate collision pairs, not for a tight dimension.\n\n```python\nfrom cadgen.geometry import closest_points\n\nresult = closest_points(a, b)\nprint(result.distance, result.point_a, result.point_b)\n```\n\n`closest_points(Shape, Shape) -> ClosestPoints` returns a nonnegative minimum\nset distance and one witness pair in the input frame. Solid overlap, contact\nand containment have distance zero; it does not measure penetration depth.\nWitnesses may be nonunique or internal to a solid. Pass selected faces/shells\nwhen asking about boundary separation. Minimum distance does not replace\ncenter spacing, angles, radii, lengths, areas or volume measurements.\n\n## Overlap and clearance checks\n\n`overlap_volume(Solid, Solid) -> float` measures the exact intersection volume\nof two finite, positively oriented solids. It returns zero for mere contact.\nIt applies no minimum volume, exclusions, part hierarchy or pass/fail policy.\nKernel failures raise; inputs remain unchanged. Length units from STEP are mm,\nso overlap volume is mm³. Tolerances in these scripts are design decisions,\nseparate from the kernel's numerical precision.\n\nFor example, checking every body pair inside a chosen subassembly:\n\n```python\nfrom itertools import combinations\nfrom cadgen import read_scene\nfrom cadgen.geometry import overlap_volume\n\nscene = read_scene(\"STEP/assembly.step\")\n(selection,) = scene.resolve(\"#housing_assembly\")\nbodies = [\n    (entity.ref, solid)\n    for entity in selection.entities(\"shape\")\n    for solid in entity.shape().solids()\n]\nif len(bodies) < 2:\n    raise ValueError(\"This interference check needs at least two bodies\")\n\nmax_overlap_mm3 = 0.01  # chosen for this design\nfailures = []\ntested = 0\nfor (ref_a, a), (ref_b, b) in combinations(bodies, 2):\n    volume = overlap_volume(a, b)\n    tested += 1\n    if volume > max_overlap_mm3:\n        failures.append((ref_a, ref_b, volume))\nprint({\"tested_pairs\": tested, \"limit_mm3\": max_overlap_mm3,\n       \"overlaps_mm3\": failures})\nif failures:\n    raise SystemExit(1)\n```\n\nAdapt pair selection to the question: for clearance around a moving carriage,\ncompare that carriage against relevant fixed geometry. List intentional\npress fits/excluded pairs explicitly. Do not infer exclusions from assembly\ndepth. For large pair sets, retain the shapes and compute conservative bounds\nonce per body. Disjoint bounds rule out overlap; intersecting bounds only\nidentify candidates for the exact query. For a clearance requirement use\n`closest_points(a, b).distance` and\ncompare against the specified minimum. A static check proves only that pose;\npath or motion checks need an explicit sampling or swept-volume strategy.\n\n## Geometry diagnostics\n\n```python\nfrom cadgen.geometry import is_sound, topology_errors, boundary_edges, self_intersections\n\nok = shape.is_valid                       # bool: BRepCheck passes (build123d)\nclean = is_sound(shape)                   # bool: the boolean kernel's argument check passes, expensive\nissues = topology_errors(shape)            # tuple[GeometryIssue, ...]\nfree = boundary_edges(shell)              # tuple[Edge, ...]\ncrossings = self_intersections(shape)     # tuple[GeometryIssue, ...], expensive\n```\n\n`GeometryIssue` has `code` and `entities`, containing owned build123d geometry.\nTopology codes are OCCT `BRepCheck_*` statuses; self-intersection codes are\n`BOPAlgo_SelfIntersect`. Failed/inconclusive checks raise `GeometryError`,\nnever an empty success result. None of these functions repairs geometry.\n\n`is_sound` is `BRepAlgoAPI_Check`'s verdict: BRepCheck-valid, no\nself-intersections, no too-small edges, an argument type a boolean accepts.\nIt is the gate a fuse or cut demands of an operand. Closure, solid count and\nsigned volume stay the script's own checks. `is_sound` is `False` for a null\nor empty shape, which the boolean kernel rejects as an argument. A check costs\nkernel time, so gate where a failure is plausible rather than after every\noperation.\n\nChoose checks appropriate to the artifact. For an intended closed solid,\ncheck topology, free shell edges and each solid's signed volume. A reversed\nsolid can pass topology validation but have negative volume; check individual\nsolids because aggregate volumes can cancel. Open shells can be valid when\nsurfaces are intended. No free edges establishes closure, not full manifold\nvalidity. Run self-intersection checks explicitly when required and report\nany checks you did not complete.\n\n## Mass properties\n\n```python\nfrom cadgen.geometry import mass_properties\n\nproperties = mass_properties([\n    (aluminum_body, 2.70e-6),  # kg/mm³; use the material density for the task\n    (steel_insert, 7.85e-6),\n])\nprint(properties.volume, properties.mass, properties.center_of_mass)\nprint(properties.inertia)\n```\n\n`mass_properties(Iterable[tuple[Solid, float]]) -> MassProperties` integrates\nuniform density per solid and sums the supplied bodies, including overlaps.\nDensities must be finite and positive. It does not infer materials or fuse\nbodies. The 3×3 inertia tuple is about the combined center of mass in the input\ncoordinate axes. With mm and kg/mm³, outputs are mm³, kg, mm and kg·mm².\n\nUse snapshots for visual review per `snapshot-review.md`. Turn visual concerns\ninto explicit geometric checks before claiming they have been resolved.\n\n<!-- File: skills/cad/references/kinematics.md (Tokens: ~5175 | Category: anthropic-skill) -->\n# CAD kinematics and animation\n\nRead this file when the user asks to articulate, pose, or animate a STEP\nmodel, or when designing or reviewing mates, couplings, pose presets, posed\nexports, or animation clips.\n\nThere are THREE systems with different lifecycles, deliberately independent:\n\n- **Geometry** is the module's constants and the factory the parameterless\n  model calls with them (`WIDTH = 10.0` … `return _bracket(WIDTH)`). Changing\n  one re-runs Python and rebuilds the outputs. They are not live in the viewer.\n- **Kinematics** is typed mates declared as PURE DATA via `kinematics=` on\n  the export decorators. It drives the viewer's pose sliders — no rebuild, no\n  Python at render time — and never moves the geometry a model writes. It\n  lives in the model's sidecar (`<name>.step.json`, written beside the\n  artifact), alongside any intrinsic material appearance.\n- **Animation** is choreography declared as a self-contained JavaScript ES\n  module string via `animation=` on `@step`. The build embeds it in\n  `<name>.step.json`; the viewer, snapshot door, and animated GLB export read\n  that document-bound copy. Animated GLB export hashes the source with the clip\n  request, so an animation edit invalidates that export. Animation targets\n  occurrences directly and knows nothing about mates. Like materials and\n  kinematics, it never changes STEP geometry or bytes.\n\n## Kinematics: typed mates\n\nKinematics is one sidecar section, independent of intrinsic material appearance.\nIt never moves saved geometry: the declaration describes how the written tree\narticulates, and the viewer poses it at render time. A STEP model with no\nkinematics, intrinsic material finishes or animation has no sidecar.\n\nOne `kinematics=` dict, closed keys `mates` / `couplings` / `poses`, on any of\n`@step`/`@stl`/`@glb`/`@threemf`. Each decorator's declaration stands alone\n(share a module-level dict; there is no cross-decorator inheritance).\n\n```python\nimport cadgen\nfrom cadgen import step\nfrom cadgen import build123d as bd\n\nKINEMATICS = {\n    \"mates\": [\n        cadgen.revolute(\"elbow\", parent=\"#upper_arm\", child=\"#forearm\",\n                        origin=(0, 0, 0), direction=(0, 0, 1), limits=(0, 150)),\n        cadgen.revolute(\"wrist\", parent=\"#forearm\", child=\"#hand\",\n                        origin=(80, 0, 0), direction=(0, 0, 1), limits=(0, 90)),\n    ],\n    \"couplings\": [cadgen.couple(\"curl\", {\"elbow\": 1, \"wrist\": 0.5}, limits=(0, 90))],\n    \"poses\": {\"straight\": {\"curl\": 0}, \"bent\": {\"curl\": 60}},\n}\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS)\ndef arm():\n    upper_arm = bd.Pos(-40, 0, 0) * bd.Box(80, 10, 10)\n    forearm = bd.Pos(40, 0, 0) * bd.Box(80, 10, 10)\n    hand = bd.Pos(90, 0, 0) * bd.Box(20, 10, 10)\n    upper_arm.label, forearm.label, hand.label = \"upper_arm\", \"forearm\", \"hand\"\n    return bd.Compound(children=[upper_arm, forearm, hand], label=\"arm\")\n\n\nif __name__ == \"__main__\":\n    arm()\n```\n\n- **Mate kinds**: `revolute` (degrees about an axis), `slider` (model units\n  along it), `cylindrical` (sub-DOFs `<name>.turn` and `<name>.travel` about\n  one axis), `fastened` (0-DOF rigid attachment — needed exactly when\n  occurrences are SIBLINGS in the instance tree, like a pin that must orbit\n  with its carrier; instance-tree children ride for free).\n- **`limits=(lo, hi)`** is required on every mate that moves, and both ends\n  must be finite: Position sliders and exports read the range. A joint that\n  turns freely takes a full range such as `(-180, 180)`; `math.inf` fails the build.\n- **`parent`/`child`** are occurrence refs: `#`-prefixed labels (canonical —\n  label parts with `cadgen.label_shape`) or occurrence ids. They must resolve\n  at build or the build fails; `read_scene(path).leaves()` lists saved geometry occurrences.\n  A label resolves **into linked children**: a part labelled inside a\n  sub-assembly you call (`#shoulder_yaw_servo` living in `base_link()`'s\n  tree) resolves to its occurrence under the link (`o1.1.1`), so an assembly\n  can mate parts of a sub-assembly it links without owning their geometry.\n  A ref may name a SUBASSEMBLY as well as a part — a labelled group `Compound`\n  is an occurrence in the instance tree, and mating it carries every part\n  beneath it. That is how a rocker-bogie chain is three mates instead of three\n  hundred. `scene.roots` and `occurrence.children` expose those groups. Targets may NEST — a part inside a mated group may carry its\n  own mate to a sibling (a servo's output horn bolted to the jaw it drives):\n  the DEEPEST mate naming a part owns it and moves it exactly once, and the\n  enclosing group carries only what no deeper mate claimed.\n- **`axis`** is a selector ref (`axis=\"#forearm.f2\"` — a cylindrical\n  face or circular edge yields its axis, a planar face its center+normal) or\n  literals (`origin=(x, y, z), direction=(x, y, z)`). Refs resolve ONCE at\n  build into world numbers; the viewer does arithmetic, never topology.\n- **ZERO IS THE ARTIFACT AS WRITTEN.** Every DOF's rest value is 0 — the\n  placement the author built. There is no `default=`; a presentation pose is a\n  preset. A model that must be WRITTEN at another configuration is authored\n  at that configuration (or is another model): no decorator argument moves\n  geometry.\n- **`couple(name, {dof: ratio})`** declares a virtual DOF gearing real ones\n  linearly and ADDITIVELY (above, setting `curl=x` adds `x` degrees to `elbow`\n  and `0.5*x` to `wrist`).\n  Exact gear trains are ratio arithmetic, not code.\n  A geared member BACK-DRIVES in the viewer: when exactly one coupling gears a\n  DOF with a nonzero ratio, its Position slider reads the effective value\n  (own + ratio x coupling), is labelled \"driven by <coupling>\", and dragging it\n  moves the COUPLING — `coupling = (target - own)/ratio`, clamped to the\n  coupling's limits — so sliding one gear turns the whole train. A member's own\n  value (from a preset or `--kinematics`) is never overwritten, and a DOF geared\n  by two couplings stays independent: that inverse is underdetermined, so the\n  viewer refuses it rather than guessing a split.\n- A declaration needs at least one mate (or coupling): a pose is a set of joint\n  values and a joint is what a mate declares, so `poses` alone declare nothing\n  and are refused. A part with no joints declares no `kinematics=`.\n- **`poses`** are named `{dof: value}` presets — all that remains of \"pose\"\n  as a concept.\n- The mate graph is a TREE: one parent mate per occurrence, no cycles.\n  Closed-loop linkages (four-bars) are out of scope by design — they need a\n  solver; the viewer evaluates pure forward kinematics from the sidecar's\n  numbers at render time.\n\n## Annotating a STEP you did not generate\n\nA document with no model script gets its kinematics from\n`cadgen step build IN OUT`, whose `--kinematics` takes the whole SPACE — the\nsame `{mates, couplings, poses}` vocabulary, as inline JSON or a `.json`\npath. `--materials` accepts the named material declaration as inline JSON or\na `.json` path, and `--animation` accepts a self-contained JavaScript module\nfile or source string that exports only `clips` (see below). The input is read\nwith OCCT and re-emitted by the canonical writer, so OUT's bytes are\ndeterministic whichever kernel wrote IN:\n\n```bash\ncadgen step build vendor/hinge.step STEP/hinge.step \\\n  --kinematics '{\"mates\": [{\"name\": \"swing\", \"kind\": \"revolute\",\n                            \"parent\": \"#body\", \"child\": \"#lever\",\n                            \"axis\": \"#lever.f2\", \"limits\": [0, 90]}],\n                 \"poses\": {\"open\": {\"swing\": 45}}}'\n```\n\n**Wrapper script or `step build`?** A model that will keep changing belongs in a\nscript — a thin `@step` function that imports the foreign STEP and re-exports\nit, so the kinematics live beside the geometry decisions and every edit is one\n`python model.py`. Reach for `step build` when the geometry is fixed and not\nyours: a one-shot annotation or canonicalization of a vendor file. Re-running it\nis a no-op, editing only these annotations refreshes the sidecar without\nre-emitting a byte, and vendor metadata (PMI, GD&T) does not survive the trip.\n\n## Animation: the embedded module\n\nA STEP document may carry one self-contained JavaScript animation module in\nits unified sidecar. Author the module as a Python string and pass it to\n`@step(animation=...)`. It exports `clips` and nothing else; leave helpers and\nconstants unexported. The renderer refuses a module with any other export, and\nevery clip with it, so the build refuses it first, in the renderer's words:\n`arm.py::arm animation: unknown export ease — the renderer understands: clips`.\nThe module has no imports. For the arm above, add this constant and update its\ndecorator, keeping the same model body:\n\n```python\nANIMATION = r\"\"\"\nexport const clips = {\n  demo: {\n    label: \"Demo\",\n    duration: 8,          // seconds\n    loop: true,           // default\n    update(t, m) {        // called every frame; t in seconds\n      const angle = 60 * (1 - Math.cos(2 * Math.PI * t / 8));\n      m.get(\"forearm\").rotate([0, 0, 1], angle, [0, 0, 0]);\n      m.get(\"hand\").rotate([0, 0, 1], angle, [0, 0, 0]);\n    },\n  },\n};\n\"\"\"\n\n@step(out=\"../STEP/arm.step\", kinematics=KINEMATICS, animation=ANIMATION)\ndef arm(): ...\n```\n\n- `m.get(target)` takes a LABEL (canonical) or occurrence-id refs\n  (`\"#o1.3.1\"`, comma lists; each id covers its whole subtree). Unknown\n  targets THROW — a typo never silently animates nothing. Labels here match\n  RENDERED PARTS only: to animate a whole group, name its occurrence id.\n- Handles support `.rotate(axis, degrees, origin=[0,0,0])`, `.translate(vec)`,\n  `.opacity(0..1)` and `.visible(bool)`. Successive transforms premultiply:\n  spin about a part's center first, then orbit the origin, and the spin rides\n  the orbit. For flexible swept bodies, see\n  [tube deformation and morph export](animation-deformation.md).\n- Every frame starts from rest and `update(t)` rebuilds the state — a pure\n  function of t, so scrub/loop/seek are free. No wall-clock, no state.\n- Animation targets occurrences independently of mates. Rerun the model after\n  editing its animation to refresh the sidecar. Literal annotation edits may\n  reuse cached geometry; computed or imported annotations can require a rebuild.\n  See [annotation caching](step-generation.md#annotation-caching).\n- The model build validates the declaration, exports included, and embeds it.\n  A model without `animation=` is simply a model without animation.\n- Targets are checked at LOAD, against the compiled tree: every clip's\n  `update(0, m)` runs once when the module loads, and a label or occurrence\n  id no part carries is reported in the viewer's Issues (`Animation\n  unavailable`, in the file's panel) and in\n  `snapshot --animation`'s error — not at the first frame that reaches it.\n- Mesh-only models (no `.step`) have no document sidecar; animation is a\n  STEP-document concern.\n\n## Reviewing motion\n\nReview motion interactively in the viewer or render a clip video as described\nbelow. For a still of the arm example's configuration, render at DOF values:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics '{\"curl\": 60}'\n```\n\n`--kinematics` is named for the `kinematics=` block it drives, and takes\neither spelling: `{dof: value}` JSON, or the NAME of a pose the model\ndeclares under `poses`. A name is checked against the declaration, so a typo\nfails with the poses this model actually has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/bent.png --kinematics bent\n```\n\nFor still evidence of a CLIP, freeze one frame: `--animation` names a clip\nthe document sidecar's embedded animation declares and `--time` the\nmoment in seconds (default 0). The frame\nis composed exactly as the viewer composes it: `--kinematics` sets the base\npose, and the clip's `update(t, m)` is evaluated at that time on top of it.\nA clip name the model does not declare fails with the clips it has:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo_t2.png --animation demo --time 2.0\ncadgen step snapshot STEP/arm.step tmp/demo_bent.png --kinematics bent --animation demo --time 2.0\n```\n\nIn a JSON job the request is one field, `\"animation\": {\"clip\": \"demo\",\n\"time\": 2.0}`, beside `\"kinematics\"`; the Python door takes the same object\n(`step.snapshot(..., animation={\"clip\": \"demo\", \"time\": 2.0})`) or the clip\nname with `time=`.\n\n### Rendering the whole clip\n\n`--video` renders the SPAN instead of a moment, into the `.mp4` or `.gif` the\nOUT names. Everything else is unchanged — same unified display settings, camera\nand size profile as a still, and the same `--kinematics` base pose underneath:\n\n```bash\ncadgen step snapshot STEP/arm.step tmp/demo.mp4 --animation demo --video '{\"fps\": 30}'\ncadgen step snapshot STEP/arm.step tmp/demo.gif --animation demo \\\n  --video '{\"fps\": 12, \"seconds\": 3, \"start\": 1.5, \"quality\": \"draft\"}'\n```\n\nThe request's keys, all optional:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `fps` | `30` | frames per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to render |\n| `start` | `0` | seconds into the clip where the video begins; must be inside it |\n| `quality` | `review` | `draft`, `review`, or `high` |\n| `loop` | `true` | GIF only — an `.mp4` carries no loop count, so `\"loop\": false` on one is refused |\n\nThe span is measured against the clip rather than trusted, because the clip\nevaluator ANSWERS a time past the end instead of refusing it: a clip that does\nnot loop holds its final pose, so an overrunning span buys frames that are all\none still image, and a looping clip wraps, so it renders a different span than\nthe one asked for. A `start` at or past the end is refused; an explicit\n`seconds` that overruns a clip which does not loop renders and warns. Hence the\ndefault: a looping clip gets one whole cycle from wherever it starts, and a clip\nthat stops gets the part of it that is left. `fps * seconds` is capped at 7200\nframes — every frame is a full-size PNG on disk before ffmpeg runs.\n\nWidth and height come from `--width`/`--height`/`--size-profile` like any\nother render; there is no size key here. `--video` needs `--animation` (it\nrenders a clip) and refuses `--time` (that freezes one frame instead), and it\nneeds **ffmpeg** on `PATH` — or `CADGEN_FFMPEG` pointing at one. A missing\nencoder is reported before the first frame is drawn, never after minutes of\nrendering. A GIF stores its frame delay in hundredths of a second, so a player\nshows the nearest rate to the one asked for; `.mp4` is exact.\n\nThe camera is fitted ONCE, to everything the clip covers, so the model moves\nand the frame does not. That is why a long clip is worth trimming with\n`start`/`seconds`: framing the whole of a clip that travels a long way leaves\nthe interesting part small.\n\nIn a JSON job the request is a `\"video\"` object beside `\"animation\"`, and a\nvideo job carries exactly one output:\n\n```json\n{\n  \"input\": \"STEP/arm.step\",\n  \"animation\": { \"clip\": \"demo\" },\n  \"video\": { \"fps\": 24, \"quality\": \"high\" },\n  \"outputs\": [{ \"path\": \"tmp/demo.mp4\", \"camera\": \"iso\" }]\n}\n```\n\nThe result names the file and what it covers — `saved video: tmp/demo.mp4\n(120 frames, 30 fps, 4s)` — so a wrong clip or a wrong span shows up without\nopening it. Still snapshots remain the evidence for a POSE; a video is for\nmotion a still cannot show.\n\n### Exporting the clip INSIDE a GLB\n\nA video is pixels. `cadgen glb build --animation` writes the motion itself: the\nclip is sampled into glTF node animation, so whatever opens the `.glb` — Blender,\na three.js viewer, a browser's model preview — plays it. There is no camera, no\nquality and no encoder here, and `fps` means something different: the SAMPLING\nrate of the baked keyframes, not a playback rate.\n\n```bash\ncadgen glb build STEP/arm.step GLB/animated/arm.glb --animation demo\ncadgen glb build STEP/arm.step GLB/animated/arm.glb \\\n  --animation '{\"clip\": \"demo\", \"fps\": 30, \"seconds\": 24, \"start\": 0}'\n```\n\nThe request is a clip name, or an object whose keys are all optional but `clip`:\n\n| key | default | meaning |\n| --- | --- | --- |\n| `clip` | — | the clip to bake; required |\n| `fps` | `30` | keyframe samples per second, a whole number 1..120 |\n| `seconds` | what is left of the clip from `start` | how much of the clip to bake |\n| `start` | `0` | seconds into the clip where the span begins; must be inside it |\n| `drop` | `[]` | effects to bake STATIC instead of refusing: `opacity`, `visible` |\n| `deform` | `refuse` | what to do with `.deformTube()`: `refuse`, `morph`, `rest` |\n| `deformTolerance` | `1.0` | `morph` only — millimetres the baked tubes may sit from the clip's own deformation, `0.01`..`10` |\n\nThe span is resolved exactly as `--video`'s is — a looping clip defaults to one\nwhole cycle, a clip that stops gets what is left of it, and `fps * seconds` is\ncapped at 7200 samples. The exported animation is re-based to zero, so `start`\npicks where in the CLIP the span begins and the file still opens at t = 0.\n\n**What glTF carries, and what it will not:**\n\n| clip effect | in the GLB |\n| --- | --- |\n| `.rotate()` | sampled rotation channel on that occurrence's node |\n| `.translate()` | sampled translation channel on the same node |\n| `.rotate()` about a pivot | sampled rotation and translation channels |\n| `.opacity()` | **refused.** glTF has no animated opacity. `\"drop\": [\"opacity\"]` bakes the value at `start` as a material alpha, and warns |\n| `.visible()` | **refused.** Same reason. `\"drop\": [\"visible\"]` omits whatever is hidden at `start`, and warns — an occurrence dropped this way loses its motion too, because a node that is not in the file cannot be animated |\n| `.deformTube()` | **refused by default.** Per-vertex motion, not a node transform. `\"deform\": \"morph\"` bakes it as glTF morph targets; see [deformation](animation-deformation.md); `\"deform\": \"rest\"` ships those tubes at rest shape and warns |\n\nNothing is dropped quietly: an effect the file cannot carry stops the export and\nnames the occurrences, so a hand whose tendons froze on the way out is a refusal\nrather than a finished-looking file. Render the clip with\n`cadgen step snapshot --animation <clip> --video` when the motion is one of\nthose — `--video` needs the clip named too, so both flags go together.\n\nFor `.deformTube()` authoring, morph fitting, memory limits and braid export\nlimitations, read [tube deformation and morph export](animation-deformation.md).\n\nThe CAD Viewer plays a GLB's embedded rigid, skinned and morph animation from its\nfile sidebar's Animation section, in the tools view and Preview alike. These are baked clips: the STEP\nsidecar module's procedural controls are not available in the exported GLB.\n\nAn animated export writes ONE node per occurrence instead of the flat,\ncolour-grouped mesh a static one writes, so the file is bigger and its parts are\nindividually addressable. Only occurrences the clip actually MOVES get channels;\nan occurrence held at a constant offset carries it on its node and nothing else.\nFreshness folds the clip request and the sidecar animation source into the export's key, so\nediting the choreography re-exports even though the STEP has not changed.\n\n`--animation` is GLB's alone, and the CLI is generated from each door's\nsignature, so `cadgen stl build` and `cadgen 3mf build` have no such flag at all\n— they exit 2 with `unrecognized arguments: --animation`. Neither format has\nanywhere to put a clip; export it as `.glb`, or render it with\n`cadgen step snapshot --animation <clip> --video`.\n\nAnimated GLB requires an explicit OUT. A static export without OUT writes one\nsibling `.glb` beside the STEP; it does not read model output declarations.\nChoose a separate destination for the animated file when retaining both.\n\nChoose pivots and axes for the intended motion. For hinged motion, use the\nphysical hinge axis. Verify the relevant dimensions, alignments and clearances\nwith geometry checks.\n\n<!-- File: skills/cad/references/migrations.md (Tokens: ~1020 | Category: anthropic-skill) -->\n# Migrations\n\nRead this file when source, command syntax or sidecars target a different\ncadgen version. First run `cadgen doctor <skill-dir>` to check the installed\nversion against the skill's pin. Follow the specific error and current command\nhelp; a modeling failure alone does not establish version skew.\n\ncadgen uses hard interface cutovers. A retired interface may fail with a\nteaching error naming its replacement; it is not a compatibility alias.\nThe retired inspect CLI is replaced by Python checks using `read_scene` and\nnative geometry; see [inspection](inspection-and-validation.md).\n\n## A migration message is a task\n\nWhen a build, snapshot or validation reports that a model needs\nmigrating (a sidecar refused for its schema version, a leftover companion file\nthe build warns about, a retired decorator argument or command named by a\nteaching error), do the migration then, as part of the work in hand. Do not\nroute around it, silence it, or leave it for later: the CAD Viewer does not\nflag a stale model, so an unmigrated one simply loses its kinematics,\nmaterials and animation and looks like a plain part.\n\n- A refused sidecar (`unsupported sidecar schema N (expected M)`) is fixed by\n  running the model's script again (`python <model>.py`), which writes a current\n  sidecar beside the STEP. For an imported STEP that has no script, re-annotate\n  it with `cadgen step build`. Never edit `schemaVersion` by hand.\n- A leftover companion render module beside the STEP (a `.js` file named after\n  the document; the build warns it \"is a retired render module\") is read by\n  nothing, so its clips are missing from the model. Move them into\n  `@step(animation=...)` ([kinematics](kinematics.md)), delete the file and\n  rebuild.\n- After migrating, confirm it took: the sidecar declares the current schema and\n  the document's hash, the build printed no migration warning, and the model\n  articulates (a `cadgen step snapshot --kinematics …` pose differs from rest,\n  or the Viewer shows a Position section in the file's panel, the Position\n  tool and, for a model with routines, its routines playing in preview).\n- If the script itself no longer runs on the installed cadgen, migrate the\n  script using the sections below, then rebuild.\n\n## When to suspect skew\n\n- **A model script runs, exits 0, and writes nothing.** An older source carries\n  no decorated function and no entry point of its own, so Python defines a\n  function and exits. Nothing looks for an entry point by name.\n- **A command or flag you are sure of comes back unknown**, and the help lists\n  an unfamiliar set. Building a model is running its script; there is no\n  generation verb. Consult current help and any replacement named by the error.\n- **A sidecar is refused for its schema version.** Sidecars are never upgraded\n  in place and never partially read, because a wrong-shaped one would cost a\n  model its kinematics silently.\n- **A model that used to articulate renders inert**, presenting as a plain\n  document with no pose and no animation. Nothing is discovered by convention:\n  kinematics and animation are `kinematics=` and `animation=` on the model's\n  decorator, and the build puts both in the document's sidecar. A companion\n  JavaScript file beside the document is no longer read. A generated model's\n  build warns about the leftover file and names the replacement decorator;\n  the warning does not stop the build.\n- **Meshes come out visibly coarser or finer, with no error.** Mesh tolerance\n  kept its name and changed meaning — chord tolerance is a fraction of the\n  component's bounding diagonal, not an absolute length — so a value carried\n  across from an older project is wrong in proportion to the part's own size.\n  A carried-over value above `0.05` is refused outright with the conversion\n  (X mm on a part whose diagonal is D mm is X/D); a smaller one is accepted, so\n  check it against the part's size.\n\nA migrated source may still have incompatible saved outputs. Rebuild or\nre-annotate the affected document as the error directs; preserve imported\nsources and do not delete unrelated artifacts to diagnose a version mismatch.\n\n## Migration guides\n\n- **cadgen 0.4 → 0.5** — generator functions became decorated model scripts, the\n  generation CLI was removed, sidecars and provenance moved, snapshot job JSON\n  was re-keyed, and mesh tolerance became relative.\n  https://github.com/earthtojake/text-to-cad/blob/main/docs/migrations/migrating-0.4-to-0.5.md\n\n<!-- File: skills/cad/references/positioning.md (Tokens: ~1317 | Category: anthropic-skill) -->\n# Assembly positioning and mating\n\nRead this file when placing assembly parts, defining mating datums or checking\nalignment. Author placements in the model source, then check the saved geometry.\nChoose explicit transforms or native build123d joints according to which makes\nthe intended relationship easiest to express and maintain. Neither requires a\nparticular assembly size or complexity.\n\n## Transforms and local frames\n\nUse functional datums and explicit dimensions for placements. A part's origin\nmight be its mounting interface, symmetry center or rotational axis; use the\nconvention that fits the design. Record it when another part depends on it.\n\n```python\n# Inside an assembly model:\nleft = bd.Pos(-pitch / 2, 0, height) * spacer()\nleft.label = \"spacer_left\"\nright = bd.Pos(pitch / 2, 0, height) * spacer()\nright.label = \"spacer_right\"\nassembly = bd.Compound(children=[left, right], label=\"spacer_pair\")\n```\n\nPlace child models with `.moved()` or `Pos/Rot/Location * child` to preserve\nshared geometry. `.located()` replaces the existing placement and copies the\ngeometry; it loses the cached child link. Labels identify roles and repeated\noccurrences, such as `m3_screw:front_left`. A functional group can be a nested\nlabeled `Compound` when the design benefits from that hierarchy.\n\nMirroring changes geometry, so an inline mirrored child belongs to the parent\nrather than linking to the original child's tree. It remains cached with that\nparent. A separate mirrored model is useful for independent outputs or reuse,\nnot a requirement; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\n\n## Native joints\n\nJoints can express a relationship between reusable datums without hand-solving\nits placement. They perform source-level placement; they are not persistent\nSTEP constraints or a replacement for the separate [kinematics](kinematics.md)\ninterface. Call `connect_to()` on the fixed joint with the moving joint as its\nargument.\n\nFor example, `src/enclosure.py` places a lid above a base with a specified gap:\n\n```python\nfrom cadgen import build123d as bd, step\n\nBASE_HEIGHT = 30.0\nLID_THICKNESS = 3.0\nGASKET_GAP = 0.5\n\n\n@step(out=\"../STEP/enclosure.step\")\ndef enclosure():\n    base = bd.Box(80, 50, BASE_HEIGHT)\n    lid = bd.Box(80, 50, LID_THICKNESS)\n    base.label, lid.label = \"base\", \"lid\"\n    bd.RigidJoint(\n        \"lid_target\", to_part=base,\n        joint_location=bd.Location((0, 0, BASE_HEIGHT / 2 + GASKET_GAP)),\n    )\n    bd.RigidJoint(\n        \"underside\", to_part=lid,\n        joint_location=bd.Location((0, 0, -LID_THICKNESS / 2)),\n    )\n    base.joints[\"lid_target\"].connect_to(lid.joints[\"underside\"])\n    return bd.Compound(children=[base, lid], label=\"enclosure\")\n\n\nif __name__ == \"__main__\":\n    enclosure()\n```\n\nNative joint options include `RigidJoint` for fixed placement, `RevoluteJoint`\nfor rotation, `LinearJoint` for translation, `CylindricalJoint` for combined\nrotation/translation and `BallJoint` for spherical orientation. Use `Location`\nfor rigid/ball joint frames and `Axis` for revolute/linear/cylindrical joints.\n\nCreating joints reads the child's placement and may materialize it earlier\nthan a deferred transform. Cached child models return geometry, labels,\nappearance and placements; do not rely on Python joint objects surviving a\nchild's cache round trip. Define the joints needed by the parent in its source.\n\n## Child models and imported components\n\nCall a project model to compose it. Use `cadgen.read_step` for a vendor document\nor an explicitly decoupled export; like every file a build reads, it is an input.\nSee the [model contract](step-generation.md) for dependency tracking.\n\nFor imported geometry, inspect its existing origin, orientation and functional\nfeatures with `read_scene` before choosing datums. Express measured offsets\nor joint frames in the source, then validate the placed result. Anchor model\ninput paths on `__file__` when they must work from any working directory.\n\n```bash\npython path/to/assembly.py\npython tmp/check_assembly.py\n```\n\n## Alignment and measurement checks\n\nSelect the actual mating features from `read_scene`, then express the intended\nrelationship in Python. There is no generic alignment mode that guesses which\npoints or axes the design means. See `inspection-and-validation.md` for the\nreader and native measurement interfaces.\n\nFor example, for two planar mating faces (with their refs already identified):\n\n```python\nfrom cadgen import read_scene\n\nscene = read_scene(\"STEP/assembly.step\")\n(moving_face, fixed_face) = scene.resolve(\"#moving.f1,fixed.f2\")\nmoving, fixed = moving_face.shape(), fixed_face.shape()\nn = fixed.normal_at().normalized()\nm = moving.normal_at().normalized()\nsigned_gap_mm = (moving.center() - fixed.center()).dot(n)\nparallel_error_deg = min(n.get_angle(m), n.get_angle(-m))\nprint(signed_gap_mm, parallel_error_deg)\nassert abs(signed_gap_mm) < 0.01\nassert parallel_error_deg < 0.1\n```\n\nThese thresholds are illustrative; use the task's tolerances. The plane gap\nand parallelism test does not establish lateral alignment or overlapping face\nfootprints. Check those when required. For opposed normals, assert the signed\norientation explicitly instead of accepting either parallel direction.\n\nFor a screw-pattern dimension, compare the analytic centers of the selected\ncircular edges. For clear space between bodies use `closest_points`.\n`shape.bounding_box().size` measures the world-aligned envelope. Use explicit\nvectors and datums for orientation, signed offsets and center spacing.\nUpdate source placements, regenerate, and rerun the relevant checks.\n\nCorrect failed positioning in the model source, then regenerate and rerun the\nrelevant checks. Report measured relationships and any intended alignment left\nunchecked; no positioning report is needed when positioning is irrelevant.\n\n<!-- File: skills/cad/references/project-layout.md (Tokens: ~2032 | Category: anthropic-skill) -->\n# CAD project structure\n\nFollow an existing project's layout. For a new multi-model project, use the\nstructure below; a one-off part can use a flat folder. In a larger workspace,\nput CAD work in its existing `models/`, `cad/` or `hardware/` area. In a bare CAD\nworkspace, use the root. cadgen does not infer paths from folder names: each\nmodel declares its outputs.\n\n## Source and output mapping\n\n**One model entrypoint per `.py` file, with matching output stems.** For\nexample, `src/bracket.py` contains one parameterless decorated model and declares\n`STEP/bracket.step`, `STL/bracket.stl`, `GLB/bracket.glb` or `3MF/bracket.3mf`, as\nneeded. Stacking output decorators still declares one model. Helpers and package\n`__init__.py` files are not model entrypoints.\n\n```\n<project>/\n  src/\n    README.md             # model catalog\n    plate.py              # one model per entrypoint\n    plate_drawing.py       # separate drawing model\n    assembly.py           # root assembly\n    chassis/              # optional grouping as the project grows\n      __init__.py\n      frame.py\n      strut.py\n    purchased/            # optional read_step wrapper models for vendor parts\n      servo.py\n    lib/                  # shared helpers and parameterized factories\n      __init__.py\n      holes.py\n  STEP/                   # raw artifacts and generated sidecars\n    plate.step\n    chassis/\n      frame.step\n      strut.step\n    imported/             # external source files\n  DXF/  STL/  GLB/  3MF/  # other requested output formats\n  checks/                 # reusable checks, or the existing test directory\n  tmp/                    # ignored exploratory scripts, snapshots and debug output\n```\n\nKeep code and notes outside the format folders. Mirror nested source paths in\noutput folders so repeated names remain unambiguous:\n`src/chassis/frame.py` → `STEP/chassis/frame.step`. For several mesh densities\nof the same model, retain the stem in separate variant subfolders, such as\n`STL/draft/bracket.stl` and `STL/print/bracket.stl`.\n\nDecorator `out=` paths are relative to the script. For `src/plate.py`:\n\n```python\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 10.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    return bd.Box(WIDTH, 10, 10)\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\nRun `python src/plate.py` from the project root. An unchanged model is a no-op.\nThe CAD Viewer catalogs the saved artifacts; the source catalog identifies what\nto build before those files exist.\n\nPut exploratory inspection scripts in ignored `tmp/` or system `/tmp`, and\nretained checks in `checks/` or the project's test directory. See\n[inspection](inspection-and-validation.md) for examples.\n\n## Imports and shared code\n\nUse ordinary Python imports. Flat scripts can import sibling models and\n`lib/` directly. For nested scripts, declare the import root with `PYTHONPATH`;\ncadgen adds no paths of its own. From the project root:\n\n```bash\nPYTHONPATH=src python src/chassis/frame.py\n```\n\nPython packages and relative imports are supported. With\n`src/chassis/__init__.py`, run the model as a module when it uses relative imports:\n\n```bash\nPYTHONPATH=src python -m chassis.frame\n```\n\nDirect execution puts the script's directory first on the import path. Avoid a\nfilename that shadows a package it imports: `frame/frame.py` can hide the `frame`\npackage when run by path. Use a distinct entrypoint name or module execution.\n\nKeep shared factories, constants and helpers in `src/lib/` or an existing Python\npackage. Subsystems may import shared models or each other where the dependency\nmakes sense; avoid circular imports. Alias a module when importing a same-named\nmodel function would otherwise shadow it.\n\nDependencies are tracked as **models by result, constants by value, functions by reach**:\n\n```python\nfrom lib import fasteners  # helper module: tracked by what the model can run in it\nfrom plate import WIDTH    # literal from a model module: tracked by value\nfrom plate import plate    # model: calling it pins its result\n```\n\n`cadgen store why src/<model>.py` explains freshness. Importing a model never\nbuilds it; calling it inside an assembly does. See the\n[model contract](step-generation.md#models-inside-a-package) for package execution\nand dependency details.\n\n## Assemblies and model boundaries\n\nA parent calls its child models inside its body. The runtime builds stale children\nor reuses their cached results and links them into the parent: **running the root is the\nwhole build**. Run `python src/assembly.py`; rebuilding a child alone\ndoes NOT rebuild the assemblies that use it, so rerun the parent to incorporate\nthe change.\n\nGive a subassembly its own model when it needs independent outputs, reuse or a\nseparate cache boundary. Local construction can remain inline or in an ordinary\nhelper. A helper executes within its caller; a child model has its own record\nand reusable result. Separate folders are useful as the project grows, but a\nsubassembly does not require one. Group related files for navigation without\nforcing the directory tree to reproduce every assembly level.\n\nMirror inline for geometry local to a model; use separate left/right models\nwhen independent outputs or reuse justify them. See\n[mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models).\nA print-only part can declare `@stl`, `@glb` or `@threemf` without `@step` and\nstill compose into an assembly.\n\nUse factory arguments for configurations, with one entrypoint per independently\nexported model. Geometry must not depend on untracked environment variables,\nworking-directory state, time or random values; the cache cannot detect those\nchanges. The files a model reads are tracked on their own (the\n[model contract](step-generation.md)).\n\n## Naming\n\n- Use an importable Python identifier for the model script's stem, and retain\n  that stem in its declared outputs. Put an external part number in a label or\n  catalog entry if it cannot serve as the shared filename stem.\n- A drawing is a separate model: `plate_drawing.py` → `DXF/plate_drawing.dxf`.\n- Avoid filenames distinguished only by case; many filesystems ignore case.\n- Preserve upstream filenames for imported sources under the format's\n  `imported/` folder. They are inputs, not generated model outputs, and do not\n  need a matching script.\n\n## Renaming or retiring outputs\n\nChanging `out=` or deleting a model does not remove its previous artifacts.\nBefore a rename, record the old output paths, any `.step.json` sidecar and\ndeclared meshes. Rename the entrypoint and its matching outputs, rebuild and\nverify the new files, then remove only the obsolete generated files by exact\npath. Preserve imported sources and unrelated artifacts.\n\n## Building many models\n\nRunning a root builds its dependencies. For independent outputs, run the\ncatalog's entrypoints; independent roots can run concurrently, subject to the\nruntime's CPU and memory admission. Avoid duplicate simultaneous exports of the\nsame document. Batch snapshot views in one JSON job when they share scene settings.\n\nWhen several agents work on an assembly, independent subsystem entrypoints let\nthem build and verify their work before the root incorporates it.\n\n## Model catalog\n\nFor a multi-model project, keep `src/README.md` short and update it when the\nentrypoints or outputs change:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Mounting plate |\n| plate_drawing.py | DXF/plate_drawing.dxf | Plate flat pattern |\n| assembly.py | STEP/assembly.step | Plate with standoffs |\n\nBuild: `python src/assembly.py` from the project root builds the assembly and\nits dependencies. Run independent entrypoints separately. Unchanged models are current.\nImported source: STEP/imported/servo.step.\n```\n\n## Version control\n\nFollow the repository's existing ignore, artifact and Git LFS policies, and the\nuser's commit instructions. For a new project, a useful default is to retain\nauthored code, reusable checks and irreplaceable inputs, while ignoring\nreproducible outputs and scratch files. Keep pinned fixtures or deliverables\nwhen the project requires them; kernel upgrades can change bytes without\nchanging geometry.\n\nExample ignore patterns for that default:\n\n```gitignore\n/STEP/*\n!/STEP/imported/\n/DXF/*\n!/DXF/imported/\n/STL/*\n!/STL/imported/\n/GLB/*\n!/GLB/imported/\n/3MF/*\n!/3MF/imported/\n/tmp/\n__pycache__/\n```\n\nThe `*` forms allow Git to descend into the format folders and honor the\n`imported/` exceptions. Git LFS is optional unless the repository requires it;\nuse it for large binary inputs when appropriate. See the\n[starter](project-template.md#project-bookkeeping) for an LFS example.\n\n## Scaffolding a new project\n\nChoose a [minimal starter](project-template.md): a single part or a small\nassembly. Add other formats, helpers, groups and imported files only as needed.\nBuild the selected entrypoint, check its geometry and review its snapshot.\n\n<!-- File: skills/cad/references/project-template.md (Tokens: ~1178 | Category: anthropic-skill) -->\n# Minimal project starters\n\nChoose the starter that fits the task. Create only the files it needs; rename\nparts and change dimensions for the design. Keep an existing project's layout.\nFor a new project, use the location and conventions in [project layout](project-layout.md).\nCommands below run from the CAD project root.\n\n## One part\n\nCreate `src/plate.py`:\n\n```python\n# src/plate.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nWIDTH = 60.0\nDEPTH = 40.0\nTHICKNESS = 4.0\n\n\n@step(out=\"../STEP/plate.step\")\ndef plate():\n    body = bd.Box(WIDTH, DEPTH, THICKNESS)\n    body.label = \"plate\"\n    return body\n\n\nif __name__ == \"__main__\":\n    plate()\n```\n\n```bash\npython src/plate.py\ncadgen step snapshot STEP/plate.step tmp/plate.png\n```\n\nReview the PNG and check the dimensions needed by the design. A second model\nrun should report `current`. A one-off part can instead use a flat folder and\n`@step` with its default sibling output.\n\n## An assembly\n\nStart with the plate above and add `src/standoff.py`:\n\n```python\n# src/standoff.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nHEIGHT = 12.0\nOUTER_D = 8.0\nBORE_D = 3.4\n\n\n@step(out=\"../STEP/standoff.step\")\ndef standoff():\n    return bd.Cylinder(OUTER_D / 2, HEIGHT) - bd.Cylinder(BORE_D / 2, HEIGHT)\n\n\nif __name__ == \"__main__\":\n    standoff()\n```\n\nThen `src/assembly.py` places two instances on the top of the centered plate:\n\n```python\n# src/assembly.py\nfrom cadgen import build123d as bd\nfrom cadgen import step\n\nfrom plate import THICKNESS, plate\nfrom standoff import HEIGHT, standoff\n\nPITCH = 30.0\n\n\n@step(out=\"../STEP/assembly.step\")\ndef assembly():\n    base = plate()\n    base.label = \"plate\"\n    post = standoff()\n    z = (THICKNESS + HEIGHT) / 2\n    left = bd.Pos(-PITCH / 2, 0, z) * post\n    left.label = \"standoff_left\"\n    right = bd.Pos(PITCH / 2, 0, z) * post\n    right.label = \"standoff_right\"\n    return bd.Compound(children=[base, left, right], label=\"assembly\")\n\n\nif __name__ == \"__main__\":\n    assembly()\n```\n\n```bash\npython src/assembly.py\ncadgen store why src/assembly.py\ncadgen step snapshot STEP/assembly.step tmp/assembly.png\n```\n\nThis is a placement example; add the fastening features the actual design needs.\nRunning the root builds its stale children. After a child changes, rerun the root.\nPlaced children use `.moved()` or `Location * shape` to keep their geometry shared.\nFor mating datums and joint relationships, see [positioning](positioning.md).\n\n## Add capabilities only when needed\n\n| Need | Add |\n| --- | --- |\n| STL, 3MF or GLB output | A mesh decorator; a mesh-only model omits `@step`. See [exports](supported-exports.md). |\n| Shared factory or hole pattern | A plain helper under `src/lib/`, with `src/lib/__init__.py`. |\n| Left/right geometry | Mirror inline, or use separate models for independent outputs/reuse; see [mirroring and caching](step-generation.md#mirrored-geometry-and-reusable-models). |\n| Subassembly | A model that calls its child models; use folders as the project grows. |\n| 2D drawing | A separate drawing model using `$dxf`. |\n| Vendor CAD | Preserve the source under the format's `imported/` folder; use `read_step` with an anchored path or a wrapper model. |\n| Durable requirement checks | `checks/` or the project's existing test directory; exploratory checks and images go in ignored `tmp/`. |\n\nOnly kinematics, intrinsic materials or animation require a STEP sidecar.\nDeclaring a mesh alongside STEP does not create one.\n\n## Project bookkeeping\n\nAdd a short `src/README.md` catalog for a multi-model project:\n\n```markdown\n# Models\n\n| Script | Output | Purpose |\n| --- | --- | --- |\n| plate.py | STEP/plate.step | Base plate |\n| standoff.py | STEP/standoff.step | Repeated spacer |\n| assembly.py | STEP/assembly.step | Plate with two spacers |\n\nBuild the assembly with `python src/assembly.py` from the project root.\n```\n\nFollow the repository's version-control policy and the user's commit instructions.\nThe [layout reference](project-layout.md#version-control) suggests defaults and\nignore patterns for new projects. Create output folders and helpers only as needed.\n\nIf the project uses Git LFS for imported sources, scope its `.gitattributes`\nentries to the relevant files or formats, for example:\n\n```gitattributes\nSTEP/imported/** filter=lfs diff=lfs merge=lfs -text\n*.step.json text\n```\n\nConfigure Git LFS before adding files covered by those rules. A file starting with\n`version https://git-lfs...` is a pointer: `git lfs checkout STEP/imported`\nrestores objects already in the local cache; missing objects need fetching from\nthe project's LFS remote before CAD tools can read them.\n\n<!-- File: skills/cad/references/repair-loop.md (Tokens: ~756 | Category: anthropic-skill) -->\n# Repair loop\n\nUse the failing command and geometry to localize the problem, correct the\nresponsible source or arguments, then rerun the failed and affected checks.\nPreserve specified features and dimensions; disclose any necessary deviation.\n\n## Source and build failures\n\n- **Syntax or import error:** use the traceback to correct the source or\n  dependency. Keep geometry creation inside model functions and their helpers.\n- **Script exits without outputs:** confirm `__main__` calls the decorated model,\n  that it returns a native shape, and that its decorators declare the intended\n  outputs. Function names are not discovered by convention.\n- **Unexpected freshness:** run `cadgen store why <model>.py`; check tracked\n  inputs and whether the parent was rerun after a child changed. See the\n  [model contract](step-generation.md#freshness-cadgen-store-why).\n- **Installation or version mismatch:** run `cadgen doctor <skill-dir>` and\n  follow the specific error. See [migrations](migrations.md).\n\n## Geometry failures\n\n| Symptom | Useful checks and possible remedies |\n| --- | --- |\n| Missing or invalid body | Check profile closure, cut placement, zero thickness and the first failing operation. Use the Python diagnostics in [inspection](inspection-and-validation.md). |\n| Missing hole/pocket | Check feature mode, selector, cut depth and the intended through-condition against the saved geometry. |\n| Wrong scale or extents | Check units, radius versus diameter, primitive alignment and extrusion direction; measure the relevant dimensions. |\n| Fillet/chamfer failure | Check edge selection and local space. Consider feature order or equivalent profile construction. Change a required radius only as an explicit design decision. |\n| Loft failure or distorted surface | Inspect wire correspondence, winding, self-crossings and disconnected sections. Prefixes or adjacent pairs can localize the problem; a successful pair does not prove a valid full loft. |\n| Slow Boolean | Time the suspect operation or inspect a stack sample. Consider simpler surfaces, tool extents, batching or staged cuts while preserving the required feature. |\n| Selector no longer matches | Reopen with `read_scene`, enumerate candidates, and measure their geometry. Numeric refs are revision-scoped. |\n| Assembly placement mismatch | Check local datums, fixed/moving order, axis direction and transform composition. Measure the required signed gaps and angles on saved geometry. |\n\nVersion-specific pitfalls and construction alternatives are in\n[build123d modeling](build123d-modeling.md). For assembly corrections, see\n[positioning](positioning.md).\n\n## Viewer and snapshots\n\nShow the result as in [Show the model](../SKILL.md#show-the-model). With the CLI viewer,\n`cadgen viewer --help` lists its options. If showing fails, report the failure and use\nPython geometry checks plus snapshots.\n\nSnapshot commands take saved documents, not model scripts. Run the model first\nwhen its output is missing; otherwise verify the input format/path, Chromium\ninstallation and the reported options. Missing derived render data is compiled\non demand; adjacent GLB/topology files are not a prerequisite. Try a single\nsupported view when isolating a request error, then restore the views needed\nfor the review. See [snapshot review](snapshot-review.md).\n\n## Check the repair\n\nWhen a repair could affect other geometry, compare before/after documents using\n`read_scene` and the dimensions, volumes or relationships that should remain\ninvariant. Resolve labels independently in each revision. For shape changes,\nnative Boolean differences can show both added and removed material.\n\nReport what failed, what was repaired and verified, and which requirements\nremain untested or unresolved. Identify any artifact that remains usable.\n\n<!-- ... +66 more modular skills available via /api/skills/earthtojake/text-to-cad?path={path} -->\n</yakaai_skills>"}