# partcad

Core Python module implementing PartCAD's digital-thread logic (packages, parts, assemblies, providers).
Source: `./src/partcad`. Tests: `./tests/partcad`. It is one of the packages inside the single `partcad`
wheel, which also carries `partcad_ide_client` (see "The PartCAD IDE viewer client" below) — run all commands
below from the repo root unless noted.

## Setup

All commands on this page run **inside the dev container**, not on the host — see "Where commands run" in the
root [AGENTS.md](../../AGENTS.md) for how to enter it. Dependencies are already installed in the image; re-run
`poetry install` only after changing `pyproject.toml`. The virtualenv is not auto-activated, so prefix the
commands below with `poetry run` (e.g. `poetry run pytest ...`).

```bash
poetry install   # from repo root; installs the whole `partcad` wheel in editable mode
```

## Test and validate changes

Running `pytest` to a clean pass is the required validation step for any change under `partcad/`:

```bash
pytest tests/partcad -x -p no:error-for-skips -p no:warnings --dist no   # matches CI (test-pytest job)
pytest tests/partcad -n 4 --timeout 300 -m "not slow"              # matches the pre-commit hook, faster locally
```

Tests live in `./tests/partcad` (`tests/partcad/unit`); slow tests are marked `slow` and excluded by the pre-commit hook's `-m
"not slow"`. Treat a failing `pytest` run as blocking — do not consider a change to this module complete until
it passes.

## Lint / format

```bash
black --check src/partcad tests/partcad     # line-length 120 (pyproject.toml)
flake8 src/partcad tests/partcad
isort --check --filter-files src/partcad tests/partcad
```

All three gate — each is a `pre-commit` hook and a `Lint (...)` job in `test.yml`, and the tree satisfies
all three, so a finding from any of them is yours. See the root [AGENTS.md](../../AGENTS.md) for the two flags that
are load-bearing (`--filter-files`, and the `Flake8-pyproject` plugin without which flake8 reads no config
at all).

## Conventions

- **Async naming**: every externally visible coroutine has a name ending in `_async`, paired with a synchronous
  wrapper of the same name without the suffix. Coroutines run on asyncio's event loop; CPU-heavy work runs on a
  separate thread pool (sized by `threadsMax`, see `sync_threads.ThreadPoolManager`). Tasks on the thread pool
  must not call coroutines that use `asyncio.Lock()`. An assembly is instantiated on the *unconstrained* pool
  instead: it computes nothing itself, and the constrained pool's slots are for what does.

- **Nested work on the thread pools** (`sync_threads`): an object built from another one -- an alias, an enrich,
  an extrusion of a sketch, a compound of an assembly -- asks for that one from the thread it is itself being
  built on, and a thread of a pool is a *slot*. **Work started on a slot runs on that slot**: `run_async` awaits
  the coroutine where it is, in the loop already running there, and `run`, `run_detached` and `complete` start a
  synchronous function at once on a thread that takes no slot. Only work asked for from outside the pools queues
  for a slot, which is what still spreads a recursive render over the whole pool. Taking a second slot from a
  slot is how `threadsMax: 2` stopped `pc render -r` for good -- and how pools of four and seven stopped with four
  and seven such objects at once. **A slot waiting for a shape's lock gives the slot back** while the holder is
  not itself working on a slot (`Shape.lock` is a `PoolAwareRLock`): the loop a command runs on holds an
  assembly's lock while the assembly's children are built, and a part's while `get_solidity_async` asks for the
  part inside it. `async with` on that lock polls instead of blocking the loop, as `sandbox_lock` does. A wait
  for another caller's preparation of a shape gives the slot back by the same rule (`sync_threads.waiting`),
  because a preparation can take such a lock. And a part or sketch a command asks for goes to the pool *before*
  its lock is taken (`Shape.get_wrapped`), so the thread holding it while it is built is the one building it, and
  objects built from it wait for that thread without giving their slots back -- except from inside a
  preparation, whose chain is a context variable a thread hop would leave behind. Together these make the pools deadlock-free at any size, which
  `tests/partcad/unit/test_sync_threads.py` and `test_nested_build.py` check with pools of one and two threads.
  So do not wait from a pool thread for a pool's queue (`executor.submit(...).result()` -- use
  `threadpool_manager.complete`), and do not take a shape's lock with a blocking `with` from a coroutine.

- **No two actions open under one name** (`pc_logging.Action(op, package, item, extra)`): the progress display
  tracks running actions by `op`, package and item alone (`logging_ansi_terminal.action_key`), so a second one
  opened while the first runs replaces it, and is timed wrong; when the display runs on the daemon for the IDE,
  that used to hang the daemon outright. Where two of one kind can run on one object at once, either they are
  different work, and the name says which (`extra`, as a
  repository plugin's queries carry their key, or as an instruction book's exploded views carry their
  section), or they are the same work done twice, and the second waits for the first under `Shape.locked()`
  and reads what it remembered (`get_bounding_box_async`, `get_solidity_async`). `tests/conftest.py` fails any
  test in which two open actions share a key, and says where each was opened.

- **A name is one object of its package, whatever its kind** (`partcad_utils/object_names.py`,
  `Project.object_kind`, `Project._refuse_duplicate_names`). Parts, interfaces, assemblies, sketches, scenes and
  software share one namespace per package -- materials, providers, repositories and part types keep their own,
  since nothing names one of those without saying what it is -- and that is what lets a command take a bare name
  and find out what it is instead of being told with `-a`/`-s`/`-i`/`-S`/`-w`. `NAMED_OBJECT_KINDS` is both the
  set and the order a name is looked for in, the most frequent first.

  A package that breaks the rule keeps the name for the declaration of the kind earliest in that order -- the one
  a bare name finds -- and refuses every other one as it loads: dropped from what the package serves, and
  recorded through `record_broken_object` with the reason, so the log, `pc test` and the Explorer all say it. An
  alias (`aliases:`) is a name like a declaration, and is refused the same way. A plugin-backed package is
  checked again each time it lists a kind, which can take a name away from a kind listed earlier.
  `add_object_config` (every `pc add`, `pc import`, `pc filter`) refuses a taken name before writing it, and
  `assy_lint.check_unique_names` says the same where the refused one is written, in the words of
  `object_names.message` -- one rule, read by both ends, so the load, the editor and `pc lint` cannot disagree.

  `object_kind(name, kinds)` is two passes, and the second runs only when the first has no answer: first what is
  in memory, about every kind -- the declarations already read, the objects already made, the alias index, the
  parts an assembly produces (`_knows_object`) -- which for a package read from a `partcad.yaml` is the whole
  answer; then `declares_object`, kind by kind in order, which is where a plugin-backed package goes to its
  repository. A coroutine asks `object_kind_async`, which awaits those fetches rather than blocking its loop.

- **What an assembly places, without building it** (`Assembly.get_subassemblies_async`,
  `get_uncached_subassemblies_async`, `Shape.is_cached_async`, `Cache.contains_data_async`): the assemblies a
  declaration points at, read from the declaration -- an ASSY file's `assembly:` links, the object an alias or
  an enrich stands for -- and then filtered down to the ones that are not in memory and not in the cache. It
  answers "what would building this actually do?" without doing any of it, which is what lets the daemon split
  an assembly build into one request per assembly (see `src/partcad_service_json_rpc/AGENTS.md`).

  The cost is why it is usable at all: a declaration read, and a `stat()` (or a `HEAD`, on the object store
  tier) per entry. `contains_data_async` is an existence check rather than a read for exactly that reason --
  what it is asked about is a whole assembly, and pulling one back only to drop it is the expensive half of
  asking. A factory says what its format references by overriding `AssemblyFactory.subassemblies_async`; the
  default is none, which is the right answer for a format that holds geometry rather than references (STEP, a
  URDF, a mesh).

- **Admission limits**: `threadsMax` also caps how many tests and how many linting checks run at once, and both
  go through `concurrency.ReentrantGate` rather than a bare `asyncio.Semaphore`. A check may run the other
  checks itself -- `ManufacturabilityTest` runs the whole suite over everything an assembly is procured from,
  from inside the call the gate has already admitted -- so nested work is charged to the permit its caller
  already holds. A
  semaphore counts it as a new arrival instead, and once as many callers as the limit are each waiting on a
  nested call, every permit is held by somebody waiting for one and the loop stops for good. That is what hung
  `pc test -r`, and with it the daemon serving it. The gate keeps one semaphore per event loop for the same
  reason `sandbox_lock.py` polls: these are taken from several loops at once, and the daemon runs one
  `asyncio.run()` per request, so a semaphore kept for the process belongs to whichever request created it.

- **Sandbox concurrency**: a wrapper runs in a sandbox *environment* -- the runtime's own conda prefix, or the
  session v-env of a package that has requirements of its own -- and `sandbox_lock.py` is what holds those
  apart. `EnvironmentLock` is a readers/writer lock keyed on the environment's path: running a wrapper reads it,
  installing into it or creating it writes, so any number of wrappers share an environment while an install has
  it to itself. `process_slots` caps how many sandbox interpreters run at once, because a wrapper is a whole CAD
  process and it is the machine, not the thread pool, that decides how many fit; `threadsMax` sizes it. Both are
  polled rather than blocked on: they are taken from several event loops at once (a part is instantiated on a
  worker thread running a loop of its own), and a task that blocked its own loop waiting for a lock the task
  beside it is about to release would never see it released. The slots are a process-wide count that can be
  **resized**, because a daemon outlives every configuration it serves: `sandbox_lock.follow(config)` sizes them
  for the configuration a request carries, and the daemon calls it in `context.create` and in `_ctx` (see
  `src/partcad_service_json_rpc/AGENTS.md`). Sized once at import, they ignored the `PC_THREADS_MAX` of every
  command a warm daemon served.
- **`threadsMax` is never below `THREADS_MAX_MINIMUM` (4)**, and the floor is applied where the value is read, in
  `partcad_utils.user_config`: the file, `PC_THREADS_MAX`, a client's configuration (`from_dict`), and an
  assignment (`--threads-max`) all go through the `threads_max` property, and what is unset is
  `default_threads_max()` (one per core, at least 4). The floor is for performance: it is the least number of
  sandbox processes PartCAD runs at once, and a recursive render spends most of its time waiting for those. It
  was introduced against the nested-pool deadlock above, and only ever made that less likely; nesting cannot
  deadlock a pool of any size now, so a lower value would not stop anything -- it is raised all the same. Warnings
  about it go through
  `pc_logging.warning_once`, which says a message once per process and holds it until a handler can render it --
  both ends find these before their log is connected. A machine with fewer cores than the floor is *noted*
  (`pc_logging.info_once`), not warned of: nothing about a command changes it, and as a warning it reached the
  first command each new daemon served, failing every "warns of nothing" check on a three-core runner. `pc
  healthcheck`'s `CpuCores` is where it is a warning.
- **A sandbox process is waited for until it is dead, not until its pipes are** (`runtime.communicate`,
  `runtime.communicate_sync`, `partcad_utils.container_service.communicate`): `communicate()` -- asyncio's
  and `subprocess`'s alike -- returns when stdout and stderr reach their end, and whatever a sandbox interpreter
  starts inherits both. So an interpreter the out-of-memory killer took while a child of it lived on was
  waited for as long as the child lived, holding its process slot, the environment's lock, the shape's lock
  and the daemon's dispatch lock: every later command, `pc daemon status` included, stalled behind it. The
  process is watched as well now: once it has exited, its output drains for `EXIT_GRACE` seconds, whatever
  can still write into its pipes is found by the pipe (`pipe_holders`: through `/proc` on Linux, and on macOS
  through `lsof`, which names the kernel handle each end of a pipe is joined to) and killed,
  and the pipes are closed from this end; the caller sees the signal (`process_crash.describe_termination`
  says SIGKILL is probably out of memory) seconds after the death. Every launch point goes through one of
  those three, and a bare `communicate()` on a sandbox process is this bug again. Nor is a pip that was killed
  mid-install recorded as having installed anything (`record_install_unless_killed`): its death is raised as
  the failure of whatever needed the package, and the next run installs it afresh. The container service
  passes `wait_after_success`, because a command that exited 0 there may be an application's launcher that
  handed its window to a child -- the wait `pc ide open` exists to make.

  A container is a sandbox process too. A `docker exec` client reports a death by signal inside the
  container as 128 plus the signal, which `runtime_python_docker.exec_status` turns back into the signal; and
  a command that failed asks `containers.stopped` whether the *container* stopped, and if it did, says how
  and lets go of it, so the next command starts it again (`DockerPythonRuntime._ended`, `Runtime._ended` /
  `_endpoint_ended` for KiCad's and a plugin's container, `ContainerPool.discard` for the remote pool).
- **A key is read once its shape is prepared** (`Shape.prepare_async(wait=True)`): a preparation hashes in what
  a shape is made of -- the file a `fileFrom` or a plugin fetches, the keys of what an assembly links to -- and a
  hash adds its files when it is first read, so a key read while one is under way is kept, half of it missing, for
  the rest of the process. Every key reader (`get_cache_key_async()`, `get_wrapped()`, `Test.test_cached()`)
  waits for a preparation begun elsewhere, and two waits are refused rather than started, because they could
  never end: one that would close a cycle of preparations (each records what it waits for), and a thread that
  holds a shape's lock waiting for a preparation on another thread, which that lock may be what stops. A refused
  wait raises `PreparationInFlight`, and the caller goes on without the key, uncached; `_PREPARE_WAIT_MAX` bounds
  the rest. `get_wrapped()` prepares before it takes its lock for the same reason. A caller that only needs the
  preparation started passes nothing and returns at once, as before.

- **Location/coordinate format**: 3D locations (OpenCASCADE `TopLoc_Location`) are represented as
  `[[x, y, z], [rx, ry, rz], angle]` — translation in mm, then an axis vector and rotation angle (degrees)
  around it.

- **One object's `pc test` as data is `test/report.py`, and what each check is, `test/catalog.py`.** The report
  runs the checks `pc test <object>` runs, through `test_cached()`, and only listens: every `failed()`,
  `warned()`, `info()`, `skipped()`, `not_applicable()` and `artifact()` a check makes is handed to the report
  collecting for that check and that object (`test._report`). A check says whether it applies in
  `inapplicable()` -- from what the object is and declares, without building it or reading the cache -- and its
  `test()` asks the same method, so the two cannot disagree. The catalog is the text of every check, read by
  the IDE's Test tab and generated into `docs/source/digital_thread/testing.rst`
  (`python -m partcad.test.catalog --write`); a test fails when a check has no entry or the page is stale. The
  interference check leaves an assembly behind for each pair it reports (`test/interference_artifact.py`): the
  two parts where the assembly places them and a sub-assembly `interference` of what they share, as `brep`
  parts read from content-addressed files under `internalStateDir`, all registered in memory under names worked
  out from the pair -- so they are found by name and cached like any part -- and kept with a cached failure so
  that a verdict read back puts them back.

- **What an object is required to be is one module** (`requirements.py`): the `requirements:` section of a
  part, an assembly, a scene or a sketch -- `features` in words, and `ports`, `interfaces` and `physics` where
  any part of each may be null and the rest is a constant or a comparison (`>0`, `!0`, joined with commas). Its
  `check_async()` is what `pc test`'s `requirements` check runs *and* what the daemon's `requirements.check`
  answers the IDE's Requirements tab with, so the two cannot disagree; nothing else reads the section. Two rules
  in it are deliberate. A location requirement's middle element is the direction a frame *faces* (its Z axis),
  not the rotation axis a port's location is written with -- "faces down" is what a requirement means, and a
  port facing down is written as half a turn about X (`facing()` is the rule). And a requirement on something
  PartCAD cannot find out fails rather than passes. What a physical property *is* is not decided here at all: it
  is `physics.resolved_async()`, the resolution `pc info` reports (stated, else lent by the material, else worked
  out from the solid, or an assembly's parts combined), so a requirement and `pc info` cannot disagree -- and an
  assembly one of whose parts weighs nothing known has no mass to compare. Which physical properties may be required is
  read off the schema (`requirement-physics`, which a test holds to `physics` less a connection's properties),
  so a field added there needs nothing here. The check is never cached: what it reads -- the requirements, the
  ports and interfaces, which can live in other packages, the material -- is mostly not what a shape's cache key
  is made of.

- **Software is not a shape** (`software.py`, `software_factory*.py`): a package's `software:` section declares
  the files a product ships with -- firmware images, binaries -- and `Software` deliberately does not inherit
  `Shape`. There is no geometry, so nothing here renders, exports, tessellates or caches a shape; what it shares
  with the shape factories is the `path`/`fileFrom` plumbing, and it shares it by following the same shape of
  code rather than by inheriting a class built for shapes. Only one type exists, `raw`; the ones that follow it
  name a firmware flashing procedure for the same file, so they belong beside `SoftwareFactoryRaw` and never as
  a second way of pointing at a file.

  A part or an assembly lists what it ships with in its own `software:`, resolved **once**, by
  `ShapeFactory.__init__`, into `software_resolved` -- that is the only place that knows which package authored
  the declaration, and an alias or an enrich hands the configuration on to packages where a bare name would mean
  something else. Every assembly's bill of materials then lists that software with the commit its package was
  read at (`revision.py`), because a firmware image, unlike a bracket, is a different file once its package
  publishes again. `lint/software.py` is what keeps that answerable: a file the package does not carry has to
  declare a `fileHash`, and `ManufacturabilityTest.software_failure()` enforces the same rule where it bites
  -- a board nobody can flash is not a board anybody can make, so a part fails the manufacturing test when its
  software does not
  resolve, cannot be fetched, or does not match its `fileHash`.

  `pc add` writes a `fileHash` by itself where it can: given a URL rather than a path it fetches the file once
  (`actions/add.py`) and records the hash of what came back, so an object added that way is pinned from the
  moment it exists. That is also why `FileFactoryUrl` raises on a failed HTTP status -- without it a 404 page
  is written out as the file, and `pc add` would pin the hash of an error page.

  `fileHash` itself is **not** a software feature and does not live here: it sits beside `fileFrom`/`fileUrl`
  and pins the *bytes* of any file a package fetches rather than carries, so it belongs to `file_factory.py`,
  which refuses a download that does not hash to it (and deletes what it refused, or the next run would skip
  the download and reuse it). It is optional in the declaration and required for reproducibility:
  `unreproducible_reason()` is the one statement of that rule, and
  `ManufacturabilityTest.reproducibility_failure()` is what makes a fetched-but-unpinned object fail the
  manufacturing test -- manufacturing is repetition, and a file
  that may be a different file tomorrow cannot be made again. There are three ways out and any one will do: a
  `vendor` and an `sku` (ordering the same SKU again is what "the same again" means for a bought thing), a
  file the package carries, or a `fileHash`. Only parts and assemblies can take the first -- the schema gives
  `vendor`/`sku` to those two alone -- so a sketch and a piece of software fall through to the file, which is
  why the check reaches sketches at all even though nothing manufactures a drawing. `lint/software.py`
  answers the same rule earlier, on the declaration, and only for software. A file a repository plugin serves
  is exempt for now (`PACKAGE_FILE_SOURCES`): a `fileHash` given for one is verified, it is simply not
  required yet.

  Keep all of it clear of the hashes PartCAD computes for itself -- `CacheHash`, a git revision -- which
  identify something PartCAD built or fetched, where this states in advance which bytes were asked for.

  That test reads more than the shape's hash covers, which is what `Test.cache_key_suffix()` exists for: a
  corrected `fileHash` has to move the cache key, or `pc test` answers the new declaration with the old one's
  failure.

- **A key must not depend on what else happens to be loaded.** A connected assembly keys on the interfaces its
  links implement, and on the mates *among those interfaces* and what they are drop-ins for
  (`AssemblyFactoryAssy.interfaces_of`), each of them instantiated before any is read, since instantiating an
  interface is what registers the mates it declares. Each mate is held under the interface declaring it rather than
  merged by target: an interface and its ancestor can both mate with one target, and a connection may read either.
  Taken from every mate the context had registered, as it was, the key grew with whatever had been built first:
  the LEGO castle's round bricks have a Technic axle hole, the castle has no axle, the car has, and the castle
  keyed differently according to whether the car had been built before it.

- **A file type may name the package that implements it** (`output.split_format`,
  `Shape._output_implementor`): `pc export -t sim-gazebo:world`, the spelling `import:`, `simulation:` and
  `pc cae --implementation` already took. It exists because there are formats PartCAD ships no implementation
  of — an engine's own scene format lives in that engine's plugin package — so the bare name has nothing to
  resolve. The named package goes in as a layer directly *above* `//builtin` rather than replacing the lot,
  unlike `import_declaration()` with the same spelling, because this section also decides where the file
  goes: `output_dir` and `prefix` are the caller's business whoever writes the file.

  Two places filter a format name against a list and both had to learn about it: `Project.render_async()`
  enumerates the file types this package and the built-in ones declare, and a path is in neither, so
  `pc export -t <package>:<type>` reported success and wrote nothing; and the daemon's
  `_validate_output_format()` rejected it before resolving it. Anything else that grows such a list needs the
  same treatment — "which types can I write" is a question a path has already answered.

- **A name with a `/` in it is a file in a sub-directory** (`output.name_to_path`,
  `Context.ensure_dirs_for_file`): the objects another file materializes are named that way -- a STEP
  assembly's components are the parts `<assembly>/<component>`, a URDF's links `<assembly>/<link>`, a Gazebo
  world's `<scene>/<model>/<link>` -- and a package may declare one like that itself
  (`examples/feature_import`). Every output path built from an object's name splits it there and joins the
  pieces with `os.path.join`, so the tree is the same one on Windows as on Linux and macOS: the separator in a
  *name* is always `/`, and no filesystem takes one in a file name.

  The directories are created when the file is written, and so is every directory above them: where the file
  goes is settled by then, and a directory nobody has made yet is the last thing between that decision and the
  file. There used to be a `--create-dirs` flag drawing a line through the middle of one path -- the
  sub-directories the *name* asked for were made, the output directory the *user* named was not -- and it is
  gone. A file the caller named itself (`filepath=`) is still written exactly where it said. `pc export`/`pc render`
  of such a part is also the one lookup in `Project._enumerate_shapes_async()` that has to be awaited
  (`get_part_async`): the part does not exist until the object that produces it has been built.

- **Built-in packages** (`./src/partcad/builtin`): PartCAD ships six packages inside itself, reachable from
  every context as `//builtin/export`, `//builtin/render`, `//builtin/import`, `//builtin/open`,
  `//builtin/cam` and `//builtin/scene` (loaded
  on demand by `Context.get_project`, see `output.py`). All but the last declare implementations — the file
  types `pc export`, `pc render`, `pc import`, `pc ide open` and `pc cam` write — in
  exactly the form a user's package declares one: a `path` to a script, its `pythonRequirements`, and the
  parameters. So adding a
  format, changing its defaults or changing which dependencies it needs is an edit to `builtin/*/partcad.yaml`, not
  to `shape.py`. The scripts run in a sandbox through `wrappers/wrapper_export.py`; they are data files, so
  anything new under `builtin/` has to be listed in `pyproject.toml`'s `package-data` and in the PyInstaller
  spec (see "Packaging" in the root [AGENTS.md](../../AGENTS.md)). The requirement strings there are the versions
  `sandbox_versions.py` pins, which `tests/partcad/unit/test_output.py` enforces — as does a check that every
  built-in package validates against PartCAD's own configuration schema, since nothing else reads them.

  One field of a file type is not a parameter of any one format but a field of the **protocol**:
  `reproducible` (`output.REPRODUCIBLE_KEY`), a boolean defaulting to `false`. It says whether this file has
  to come out byte-for-byte the same every time it is written from the same object, and
  `Shape._run_implementation_locked()` puts it into *every* request, declared or not — so an implementation
  reads `request["reproducible"]` without asking whether the key exists, and a package that publishes a
  `render:` implementation of its own means by it what `//builtin/render` means. It is not reserved: it has to
  reach the script. What it costs is why it is off by default — the SVG
  projection takes OpenCASCADE's exact hidden-line algorithm over the polygonal one, which is much the slower
  on anything large and is the one that reads past the end of an OCCT allocation. It is a floor and not a
  promise: it settles what PartCAD chooses and leaves the kernel's own arithmetic, on which two architectures
  can disagree along a curved silhouette. (Not to be confused with the `reproducible` of
  `file_factory.unreproducible_reason()` below, which is about whether an object can be *made* twice, not
  whether a file is written twice the same.)

- **Engineering analysis** (`./src/partcad/cae.py`, `Shape.analyze_async()`, `./src/partcad/test/cae.py`):
  `pc cae fea`/`dynamic`/`thermal`/`cfd` are a third output section, `cae:`, resolved by the very code that resolves
  `export:` and `render:` -- same `path`/`package`, same sandbox, same meta-wrapper (`wrapper_export.py`), and
  `Shape._run_implementation_async()` is the body all three share. It is deliberately **not** in
  `output.SECTIONS`: that tuple answers "which sections does a file type of `pc export`/`pc render` live in",
  and a `fea` left in there would be offered to `pc render -t` and would fall back to a render implementation.
  It also has no built-in package -- PartCAD ships no solver -- so `output.builtin_project()` answers `None`
  for it and everything downstream has to cope with a missing bottom layer.

  What is genuinely new is the two halves either side of the script. Going in, the *part* declares the
  boundary conditions in a section named after the analysis, because they belong to the part and not to
  whoever analyses it -- as a map of named **scenarios**, the way `simulate:` is a map of simulations, since a
  part is asked more than one question of each kind. `cae.of_shape()` reads the section into `cae.Scenario`s
  (name, `desc:`, `feature:`, `implementation:`, `params:`) without failing on a malformed one, so a listing --
  `cae.list`, the IDE's tabs, `partcad.requirements`' feature references -- never fails on one scenario's
  mistake; `Scenario.conditions()` parses the conditions and raises, naming the scenario (`'fea: loaded:'`).
  The form a section had before scenarios, the conditions themselves, is still read as one scenario named
  `cae.LEGACY_SCENARIO` and warned about once: it is told apart by its keys, which is why no scenario may be
  named after one. `Shape.analyze_async()` runs **one** scenario (`scenario=`, or the only one) and writes its
  model to `<part>.<analysis>.<scenario>.<ext>` (`cae.model_suffix()`; the unnamed form keeps
  `<part>.<analysis>.<ext>`); every caller that wants several -- `pc cae`, `pc test` -- loops, one scenario
  after another, and reports each on its own. The request an implementation is handed is the one scenario's,
  unchanged in shape, so an implementation knows nothing of scenarios. `cae.ROLES` says which keys each analysis reads and how -- a *set* (`fix:`, `outlet:`)
  or a *value* parsed into one stored unit (`load:` in newtons, a bare number being a mass weighed at
  `GRAVITY`; `temperature:` in Celsius; `heat:` in watts; `convection:` in W/(m^2*K)) -- so a fifth analysis
  is a row in that table, a CLI module, a test class and a schema definition, not a new parser.
  `assign_ports()` attaches the conditions to the ports `shape_ports.ports_async()` already knows how to find,
  with the interfaces drawn, so each record carries its port's **sketch** -- and `pc render --with-ports
  --with-internals` draws exactly what a solver was told. In the sandbox, before the script runs,
  `wrappers/cae_support.prepare()` imprints every sketch on the part and puts on each record the faces of the
  part it covers (a sketchless port covers the face it lies on); the same module meshes with gmsh with those
  faces named and writes the result glTF, so the solver-independent half of every implementation lives here
  once. Coming back, the implementation reports **findings** beside the file it wrote, a JSON array that
  `pc cae` prints, `pc test`'s per-analysis checks fail on, and the IDE lists under the model. `cae.py` imports
  nothing from `partcad`, which is what lets it be tested without a sandbox.

  Both checks are gated on the part *declaring* the section -- and then run every scenario of it -- and that
  gate is the whole cost model: a
  `pc test -r` over a package tree must not start a solver for every bolt in it, and a bolt with no `fea:` has
  nothing to tell one. Declaring `fea:` is how a user asks for the check, which is why it needs no flag.

  A **missing or misconfigured plugin fails**: the implementation is named `<package>:<file type>` by the
  part's `implementation:` or by the user configuration, and if that package is not a dependency, did not
  load, or declares no such file type, the configuration is wrong on every machine and no install mends it.
  `CaeTest` resolves it separately from running it, so the two are told apart.

  **An analysis that does not run also fails**, and this is deliberately not a skip. A skip says the question
  does not apply here; a plugin that resolved, was asked, and produced no answer has failed -- no mesher, no
  solver, a sandbox that will not build, a crash. `CaeTest` cannot tell those apart and does not try: it
  relays whatever the implementation said, through `cae.dysfunction_report()`, which adds the two things the
  sentence usually omits and the reader always needs -- which implementation was asked, and which machine it
  did not work on. Whether that is a failure or the one excuse -- an implementation that names a `container:`
  or a `dockerImage`, on a machine with no container runtime; one that names neither is never excused -- is
  `ImplementationTest._verdict()` in `./src/partcad/test/implementation.py`, which the `sim` check shares:
  one rule for a missing solver and a missing simulator, not one per check.

  This was the other way round until it was found to be hiding things worth failing over: a CFD implementation
  that never converges, and a plugin that cannot be installed on a whole platform. The consequence is the
  point -- declaring `fea:` in a shared package makes `pc test` fail for everyone who has not installed what
  the implementation needs, which is what declaring it means. A package that does not want that should not
  declare the section, the same gate that stops `pc test -r` starting a solver for every bolt in a tree.

  That failure is the one verdict `pc test` does **not** cache, via `Test.NOT_CACHEABLE` on the `test_ctx`. A
  cache key describes the question -- the shape's hash, the boundary conditions, the implementation and its
  options -- and nothing in it describes the machine, because a test cannot know what its implementation needs
  installed. Installing CalculiX therefore changes no key, and a remembered failure would go on failing a part
  that now analyses perfectly well. `CaeTest` is the test the flag was made for; `CamTest` sets it for the
  same reason, and so does `SimTest`, for every verdict that was not reached on a keyed run (see below).

  A verdict that *is* cached keeps its reasons. The entry's first byte is the verdict -- all a pass is, and
  all every entry was before -- and a failure carries what `failed()` said while it was reached, as JSON,
  so a cache hit repeats the reason instead of "Failed test result loaded from cache". `failed()` collects
  through a context variable `test_cached()` sets for its own check and shape only, because a check that
  tests other objects on the way (an assembly's manufacturability) logs their failures too, and those are
  theirs to remember. Test entries are outside every tier's size window (`cache_backend.SIZED_KEYS`).

- **What a solver, a route implementation or a simulator produced is cached**
  (`./src/partcad/cache_artifacts.py`, `ctx.cache_artifacts`): the verdict cache above remembers one bit, and
  `pc cae`, `pc cam` and the IDE's tab want the file and what came with it, so `Shape._analysis_run_async()`,
  `Shape._route_run_async()` and `simulation.run_async()` all ask `cache_artifacts` before starting the
  implementation and store what came back after. The entry is the result plus the files the run wrote -- the
  model or the route program by *role*, so it goes back wherever the next caller asks for it; a simulation's whole run directory, with the directory's path in the result replaced by a
  placeholder, so a hit read through a shared tier points at this machine's copy. One entry, so a hit is never
  a result whose model went missing.

  The key (`question_hash`, built by `Shape._artifact_hash()` for the two output sections) is the subject's
  `get_cache_key_async()` plus what the run adds: the request minus the geometry payload (boundary, job,
  machine, resolved options, material facts), the implementation's name and
  `Implementation.environment_cache_key()`, the **content** of the implementation's script and of the
  wrapper that runs it, and `Implementation.source_cache_key()` -- every `.py` under the implementing
  package (its helpers: `mujoco_common.py` beside the MuJoCo plugin's scripts), plus the commit of a package
  imported as `type: git` -- and the same for PartCAD's `wrappers/` (`source_key.wrappers_key()`).
  `./src/partcad/source_key.py` says why those two and nothing more: a package is also where runs write
  their models and pictures, which a key over every file would be moved by; a revision alone misses a
  local edit. Each file is read once per process and re-read only when its size or modification time
  changes, or while it was written in the last three seconds (the "racily clean" case) -- the
  modification time decides whether to read, never what the key is. On top of all that, the **version**
  the script and the wrapper report (`code_version`), the same `CACHE_VERSION` a shape is keyed on.

  **Nothing in a key may describe the sandbox that runs it.** Every sandbox type -- conda, venv, none,
  docker, remote, and the ones after them -- is expected to be reproducible and equivalent, so an entry
  written under one has to be found under all of them and through a shared tier. Environments are therefore
  built from declarations alone: the interpreter asked for, `environment_requirements()` (the pinned stack,
  the package's and the object's own), and the image *declared* (`dockerImage`, `container:`). Never
  `runtime.image` -- PartCAD's own base image carries the release and the architecture in its tag, so keying
  on it moved every key on every release and split docker from conda -- and never the Node.js a `none`
  sandbox found on the host. The artifact key is worked out without asking for a runtime at all, and
  `tests/partcad/unit/test_cache_artifacts.py` makes asking for one fail. The machine has to come from the
  request because `manufacturing:` is outside the shape's hash: a part moved from a router to a laser keeps
  its key and must not get the router's program.
  For a simulation the subject is the *scene*, whose key covers the subject through its `subject` parameter
  and its links, plus the plugin, the declaration's `params`, how the scene is exported for it, and the
  scene's world as the facts it *resolved* to (a `medium:` is a reference into another package, whose
  density can change without the scene's key moving), and the pictures the run is asked for
  (`simulation.snapshot_request`: the scene's `render: png:` viewpoint and size), which are files in the run
  directory and so come back with a hit -- a scene looked at from somewhere else is another question, not
  last viewpoint's pictures. The
  `validation:` is deliberately not in it: it is re-evaluated on a hit, so editing it re-judges the run. A
  subject with no key (`cache: false`) is never cached, and only successes are stored, for the reason the
  verdict cache does not store an unrunnable analysis. The key is worked out without building the subject, so
  a simulation hit skips building the scene, exporting it and running the plugin alike.

  The `artifact` key is in `cache_backend.MAX_ONLY_KEYS`: a tier's maximum applies and its minimum does not,
  and `store_async` stops packing as soon as the compressed entry outgrows every tier. A simulation's run
  directory is emptied before each run, so nothing an earlier run left there is stored as this one's.

- **Routes** (`./src/partcad/cam.py`, `Shape.route_async()`, `./src/partcad/builtin/cam/`):
  `pc cam` is a fourth output section, `cam:`, resolved by the very code that resolves the other three, and
  out of `output.SECTIONS` for the reason `cae:` is. It differs from `cae:` in one thing that matters: it
  **has a built-in package**. A route is arithmetic on the object's own outline rather than somebody else's
  program with a release cycle of its own, which is the test `export:`/`render:` pass and a solver does not,
  so `//builtin/cam` ships and `camImplementation` names it by default.

  The object declares the job in its **`manufacturing:`** section, beside the method and the machine it
  belongs to -- so `cam:` means the file-type registry and nothing else. It used to mean both, with the
  ambiguity managed by keeping the two key sets disjoint; there is nothing left to manage. `//builtin/cam`,
  the package and the object are still the three layers, and within the object there are two more: what sits
  directly under `manufacturing:` is shared by every machine it names, and what sits inside a machine's own
  subsection outranks it. `cam.KEYS` is still a **closed** set, and a key that is
  neither is refused with a sentence rather than passed through.

  `cam.py` parses and converts (lengths to millimetres, feeds to millimetres per minute, and both at *every*
  layer through `normalize_job()` -- a `2400 mm/min` written by the package is as much PartCAD's to understand
  as one written on the object). Like `cae.py` it imports nothing from `partcad`, which is what lets it be
  tested without a sandbox. It requires nothing, on purpose: "a route needs a cutter diameter" is
  `//builtin/cam`'s statement about itself, not PartCAD's about a plugin it has never seen.

  The section is also the object's **opt-in**, and `Project.routable_shapes_async()` is where that is read:
  `pc cam` with no object named visits every sketch and part that declares one and passes over the rest
  silently, which is why `cam.declared_config()` exists beside `config_of()` -- deciding what to visit must
  not raise on a neighbour's broken section. Sketches and parts only; an assembly is put together rather than
  cut.

  Coming back, the implementation reports **stats** beside the file it wrote, the way a `cae:` one reports
  findings, and `wrapper_export.py` passes them through without interpreting them: what is worth counting
  differs between a router and a wire EDM.

  `pc test` runs it as the `cam` check (`./src/partcad/test/cam.py`), which is `CaeTest`'s shape over
  `route_async()`: the same gate (declare the section or the check does not apply), the same cache key
  (the job, the implementation, and its resolved options), the same refusal to call a failure a skip. One
  thing differs, and it is where the file goes: an analysis keeps its model beside the package because the
  model is the answer somebody asked for, while a route a *check* produced is a by-product that would be
  indistinguishable from the one `pc cam` writes -- so the check routes into a temporary directory and
  deletes it.

  **The check that used to be called `cam` is `manufacturability`** (`./src/partcad/test/manufacturability.py`
  and its four method-specific siblings). It asks whether an object can be made or bought at all; this one
  asks whether the program that makes it can be produced. One word answered both until `pc cam` existed. `-f`
  filters by name prefix, which is what makes the split clean: `-f manufacturability` selects that check and
  its siblings, `-f cam` selects the route check alone. Renaming a check changes every cached verdict's key,
  so the first `pc test` after this re-runs everything -- once.

- **A subtractive part names what it is cut from and what cuts it** (`part_config_manufacturing.py`,
  `test/manufacturability_subtractive.py`, `test/manufacturability_machine.py`): `subtractive` was a label until
  this -- a part declared it and nothing read it. It now carries two claims, and each is checked.

  `source:` is the stock. Cutting only removes material, so the part has to be what is left of it: nothing of it
  outside the stock, and the stock bigger somewhere. Both halves, because each catches a different mistake and
  neither implies the other -- a part that pokes out cannot be cut from it at all, and a part that fills it
  exactly is one whose `source:` names itself. Measured by `wrapper_manufacturability.enclosure`, which returns
  the four volumes rather than a boolean so a failure can say whether it missed by a rounding error or by a
  feature. Required, like `sheet_metal`'s: subtraction is *defined* by what it starts from, so a declaration naming no
  stock has not said what the method means. That is a breaking change to a method parts already declare, and
  the fixtures in this tree that carried `subtractive` as scaffolding were moved to `additive` rather than
  given a stock they do not have -- see the note at the top of each.

  The **machine** is named by adding its own subsection -- `cnc:`, `drill:` or `laser:` -- rather than by a
  `machine:` key, because the three do not take the same options and one namespace would leave nothing to say
  which belongs to which. None of them is CNC: the machine that can make anything the other two can, and what
  every `subtractive` part written before this meant. **Several of them is legal, and they are alternatives** --
  ways the part could be made rather than stages it goes through, so every one is checked and `pc cam -m`
  picks which to write for. A part that really is machined in stages is a chain of parts each naming the
  previous as its `source`, because each stage has its own geometry and its own stock.

  `MACHINE_JOB_KEYS` gives each machine only the keys its emitter actually reads, which is what the flat
  namespace could not do: a laser has no `diameter:` (it has no cutter -- `kerf:` is what it removes) and no
  `depth:` or `safe_z:`; a drill has no `depth:`, `feed:` or `operation:`. Writing one of those in that
  machine's own subsection is an error naming what it does take; writing it in the shared scope is fine and
  simply not read. The axis is `toolAxis:` rather than `direction:` because `direction` already means climb or
  conventional, and both would reach one implementation in one request. Every machine (and every saw cut) also
  takes it as `along:`, a synonym; naming both is an error (`written_axis`).

  `_read_machines` reads none of this for a method that is not `subtractive`, and **refuses** the keys rather
  than dropping them: nothing takes a cut from an `additive` part, so a `diameter:` on one is a number somebody
  chose and nothing acts on -- the failure the move out of the object's own `cam:` section was for, one level
  down. The schema says the same thing as an `if`/`then` on the method, so `pc lint` catches it too. The one
  section with no `method:` is a **sketch's**: a drawing is not made out of anything, so it declares the machine
  and the job and nothing else, which is why the gate reads "subtractive, or a declared section with no method".

  The two limited machines get a check each (`manufacturability-laser`, `manufacturability-drill`), and each
  applies **only to a part that named it** -- a package that has said `method: subtractive` for a year must not
  start failing a check about a laser it does not own, which is what `MachineConfig.declared` is for. Both rest
  on `wrapper_manufacturability.wall_alignment`, which classifies every face against the machine's axis by
  **sampling its normal** rather than by reading its surface type: a cylinder is a wall when it is coaxial with
  the axis and a defect when it lies across it, and a spline extruded along the axis is a perfectly good wall no
  type test would accept. Drilling asks one thing more and asks it of a different subject -- the material the
  machine *took away*, which is `source` minus the part -- because a drilled plate's straight sides came with
  the stock and asking the part's own walls would fail every plate for having them.

  The drilling *route* asks the same question a third time and has to answer it from geometry too: `_holes`
  takes a cylindrical face about the tool axis, and the outer wall of a round plate is one of those. What
  separates a bore from a boss is which side the material is on, so `_encloses_material` compares the face's
  outward normal against the radial direction from the axis -- `TopAbs_REVERSED` alone says how a face is used,
  not where its material is. Without it a round blank is one enormous hole with a plunge at its centre.

  `pc cam` writes for all three from the one `gcode` file type, and which one is the part's own statement
  rather than a job parameter anybody may re-tune (`Shape._route_machine_data`, applied after every other
  layer so nothing can override it, and `-m` chooses only between the machines the part itself named):
  what a part is made on is a property of the part, and a route for a machine nobody owns is the failure that
  reaches the shop floor. A part that names no machine produces the bytes it always produced, which is worth
  keeping true -- `_orient` is the identity for the default `-Z` precisely so that it stays so. A part that
  names one **unreadably** is refused rather than routed: `_read_machine` records that in `machine_error` and
  returns no machine, which is the same answer it gives for a part that named none, so falling back would hand
  a router program to somebody who wrote `laser:`. `pc test` reporting it as well is not enough, because
  nothing makes `pc cam` wait for `pc test`.

  Which machine it is belongs in a cache key wherever it is read -- `manufacturing:` is one of the keys a
  shape's hash deliberately leaves out, so a part moved from CNC to laser has the same hash and a different
  program. `CamTest` folds in `_route_machine_data`, and the two machine checks fold in the machine and the
  axis, plus the `source` for the one that judges what was removed and not for the one that does not.

  `examples/produce_part_subtractive` is the whole of it, and its laser-cut `blank` is what the sheet metal
  example bends -- named across packages, so the piece that goes into the brake is a part whose own making is
  described rather than one asserted to exist.

  The fourth machine, **`cut:`**, is a saw cutting the stock across -- a board to length, a sheet to size -- and
  it is described by *where* it cuts rather than how, in the part's (and so the stock's) coordinates. It takes a
  `toolAxis:` like every other machine, pointing at the offcut, and `cuts:`, each of which is a `length:` -- how
  far the saw travels into the stock, from where the stock starts along the axis, before it cuts across -- and,
  optionally, an axis of its own: `toolAxis:` or its synonym `along:`, never both, as for the machine itself. A cut naming neither travels
  along the machine's `toolAxis:` (default `-Z`). There is no plane syntax: every cut is a length. Any number may
  be `$name`, the part's own parameter (`parse_cuts`), because a part cut to length is nearly always parametric.
  It takes no job keys, and it is not in `ROUTED_MACHINES`: `pc cam` passes over a part that is only cut
  and refuses `-m cut`, and a saw beside a laser leaves the laser as the one route. `manufacturability-cut`
  (`test/manufacturability_cut.py`, `wrapper_manufacturability.cut`) makes each cut in the stock and requires
  the result to *be* the part, and every cut to take something off. The furniture desk in
  `partcad-furniture-basic` is built this way from `//pub/svc/commerce/homedepot` lumber.

  An **alias or enrich may state how its object is made**: `PartFactoryAlias.get_final_config` layers the
  reference's own `manufacturing:` over the source's, whole, the way the purchasing record is -- which is what
  lets a package say "this 4x4 of the standard's is cut off a store's 8 ft. one". And a reference answers
  `get_tolerance()` with its source's, since it has nothing of its own to state one in.

- **A made part is procured as its stock** (`procurement.py`): a part with a `manufacturing:` method is made, and
  the user is taken at their word that they can make it -- so no supplier is asked for it. `procured_as` is the
  one rule: bought -> itself; made -> the `source:` of its `manufacturing:` section, followed down the chain; made
  from nothing it names -> nothing; neither -> itself. `get_supply_bom(ctx)` (the cart), the grouped BOM's `stock`
  and `manufactured` sections (the readme and the instruction book), the detailed BOM (`pc bom`, which lists a made
  part *as* its stock, and a sub-assembly with a vendor/SKU as one line item, exactly as the cart does -- and a
  single part the same way, through `part_bom_detailed_async`), and `ManufacturabilityTest.stock_failure` all go
  through it. `get_supply_bom()` *without* a context
  is still "what has to be had", parts as themselves, and is what the manufacturability test walks, because a
  made part is something it tests too. A part with both a vendor/SKU and instructions is tried as bought first
  and falls back to being made. One piece of stock per part: nesting is not modelled yet. The instruction book's
  manufacturing pages repeat each part's instructions as text (`manufacturing_instructions.py`) and draw its
  route from above where one can be produced (`cam_preview.py`).

- **Build vs Buy decides the build plan, and the plan decides the instruction book** (`build_plan.py`): the
  tree of an object's line items says what each can be (bought, built, both, neither); `effective_choice` is
  the user's choice where both are possible and "buy" until they make one, the same default `procurement`
  takes. `plan()` turns the tree and the choices into the order things are done in -- each assembly's links in
  file order, with whatever a link needs made placed just before it, and a thing used in several places made
  once, in the lowest-level assembly holding all of them. `assembly_guide.build_guide_async` writes its pages in
  exactly that order and returns which page is which item's; the IDE's Build tab lists the same plan, so the two
  cannot disagree. The IDE recounts the Build vs Buy rows itself (`ide/vscode/src/webview/bvb.ts`), so the
  effective-choice rule is written there too. The choices are kept by clients only (`partcad_utils/garage.py`).
  "Can be built" in that tree is what `pc test -f manufacturability` would accept without geometry or a
  supplier: instructions that are complete, a tolerance, a pinned file, an ASSY file for an assembly (the
  `problems` of a node, worded as the test words them; `test/manufacturability.tolerance_failure` is shared).
  `manufacturable: false` makes a node missing unless something manufacturable it is used in overrides it -
  the test's `force_manufacturing`, which `--ignore-manufacturability` turns on at the root. An ASSY assembly is buildable
  only if every step says what it joins and how it goes on (`Assembly.get_step_problems`, shared with the
  `manufacturability` test; `ConnectHow.motion_declared` reads the connection's `how`, the mating and the
  interfaces).

- **A sheet metal part names what is bent and how** (`part_config_manufacturing.py`,
  `test/manufacturability_sheet_metal.py`, `wrappers/dxf_metadata.py`): `sheet_metal` is the one manufacturing
  method that is not described by the part alone. The others say how a shape comes out of stock; this one says
  an existing flat piece went through a brake, so its `manufacturing:` section carries a `source:` (the part
  that is bent) and an `instructions:` (the sketch that says where the bends are), both required and both
  resolved as references against the part's own package. The outline, the holes and the cut-outs belong to the
  `source`, which is cut flat and so is usually an ordinary `subtractive` part -- they are deliberately
  **not** describable inside the sheet metal step, because the process that bends cannot make them. `source`
  is not held to that method, though: what the check asks of it is that it is *flat*, so a bought-in blank
  declaring no method, a `forming` one sheared to outline, and an `alias` or `enrich` of a part declared
  elsewhere are all blanks it takes.

  `ManufacturabilitySheetMetalTest` asks one question of each half: the blank is flat top and bottom (the
  horizontal plane through its extreme Z meets it in an area rather than at a point --
  `wrapper_manufacturability.flatness`, which sums the horizontal planar faces at each extreme rather than
  taking a boolean, since a tolerance deciding whether two planes are the same plane is the very thing being
  measured), and every bend line states a positive `angle` in degrees, a positive inner `radius`, and a
  `direction` of up or down. It is the one test whose `cache_key_suffix()` has to *resolve another object* to
  state what it read -- `manufacturing:` is one of the keys a shape's hash deliberately leaves out, and so is
  the drawing the instructions come from -- which is why that hook is a coroutine.

  `examples/produce_part_sheet_metal` is the whole of it in one package, and is what the end-to-end
  `@pc-test-sheet-metal` scenario in `features/test.feature` runs against.

- **A sketch says what its drawing said** (`Sketch.get_annotations`, `shape_envelope.METADATA_ANNOTATIONS`,
  `wrappers/dxf_metadata.py`): BREP has nowhere to put an angle written against a line, and a DXF says exactly
  that in XDATA. So the import reads it and it rides back on the **envelope**: one record per element -- its
  type, layer, handle, where it is, and the key/value pairs -- keyed to the same layer filters the import was
  given, so the annotations describe what is *in* the sketch. This is what makes sheet metal instructions *a
  sketch* rather than *a DXF file*: `dxf` is the only type that states anything today, and nothing downstream
  knows that.

  A drawing also says things about **itself** - which layers it has, what `$INSUNITS` says its numbers are
  in, which applications it declares - and that is read on the same trip and travels the same road, under
  `METADATA_SECTIONS` rather than `METADATA_ANNOTATIONS`. The two are separate sections because they are read
  differently and not because they arrive differently: a sketch *is* the layers its filters selected, and the
  interesting thing about the ones they did not select is that they exist. `pc info` is what prints it.

  Which annotated elements are worth **showing** is decided in `dxf_metadata`, not in the core, and lands in
  `sections` under a heading of its own. Picking them out means knowing that a record has a `metadata` key and
  that an empty one means un-annotated, which is DXF's vocabulary; the full list travels on untouched, because
  a check that every bend line states its angle has to see the lines that do not.

  **Which layers of a drawing a sketch reads is an object-type parameter**, not merely a field
  (`sketch_factory.py`, `sketch_factory_dxf.py`, `Project.declare_object_type_parameters`). `include` and
  `exclude` are contributed by the `dxf` type the way `material`/`color`/`tolerance` are contributed to a
  part, with the sketch's own registry of policed names; the top-level fields stay what they always were, as
  the default. What that buys is a reference setting them -- `bends;include=BEND_UP,BEND_DOWN` -- on a sketch
  that declares no `parameters:` at all, which is one drawing read as many ways as there are uses for it
  instead of one declaration per combination of layers. Two pieces make it work: `get_object` declares the
  accepted names on the object's behalf when a reference sets one (a name the type does **not** contribute is
  left alone, so a typo stays a typo), and `parse_parameterized_name` treats a comma-separated fragment with
  no `=` as a continuation of the value before it, because a list value has commas in it and a comma is also
  what separates parameters.

- **A file says what it says, and the wrapper is what hears it** (`wrappers/step_metadata.py`,
  `shape_envelope.KEY_METADATA`): the STEP half of the same idea, on the same rails. A STEP file states a header, its products, its layers (`PRESENTATION_LAYER_ASSIGNMENT`) and
  *properties* - a `PROPERTY_DEFINITION` tied by a `PROPERTY_DEFINITION_REPRESENTATION` to a `REPRESENTATION`
  whose items are the key/value pairs - and that chain is how an `angle` written against a bend reaches
  PartCAD from a STEP file, exactly as XDATA is how it reaches PartCAD from a DXF. Both readers lower-case
  their keys and keep their values as the file states them, so what reads a pair does not have to know which
  format answered.

  It is read **in the wrapper**, which is the process the file is open in, and it is read as *text* - not for
  want of a kernel, but because XCAF gives names, layers and colours and has nowhere to put an arbitrary
  property, which is the half it exists for. OCCT has just parsed the same bytes in the same process, so a
  second pass over them is the cheap part. Only the exact entity `PROPERTY_DEFINITION_REPRESENTATION` is
  followed, never a subtype: `SHAPE_DEFINITION_REPRESENTATION` is one, every file holding a solid states one,
  and following it would report each solid as a property set holding nothing. Note the trap a test names: an
  argument list cannot be matched up to the next `;`, because every exporter writes `'2;1'` in the header.

  Nothing about any of this is in the core. The core carries the metadata opaquely from the envelope into its
  cache entry and merges it into `pc info` **as the wrapper named it**, so the sections stay the format's own
  vocabulary and the core never learns one.

- **How big it is and how much of it there is** (`shape_measure.py`, `ocp_serialize.encode_shape`,
  `Shape.get_measurements_async`): `pc info` reports a shape's `BoundingBox` and, where it holds a solid, its
  `Volume` and `Solids`. Neither can be read off a declaration - a part is a script, a file or a boolean of
  two others - so building it is the only way to know, and it is where `/pc:describe` gets the size it would
  otherwise estimate off a projection rendered to fit its frame.

  It is measured **as the shape is encoded**, in the process that built the geometry, and comes back in the
  envelope's `measurements` section. Every wrapper that returns a shape returns it through `encode_shape`, so
  every wrapper produces this without knowing it does; the core's one in-process encoder (`Shape._to_envelope`,
  for the factories that still build a live shape) does the same thing for the same reason. The arithmetic is
  over geometry that is already in memory and already being traversed to write its BREP, so it costs a
  traversal rather than a process. Measuring later would mean a fresh sandbox, the BREP shipped into it and
  deserialized, to compute what was free at build time. `shape_measure.bbox` drops the gap OCCT pads a box by,
  so a 120 mm block measures 120.

  `Shape.get_bounding_box_async` reads that recorded box too; it does not measure a shape that carries one.
  `wrapper_measure` still exists for what was not measured at build time: `measure.bbox(frame=...)`, which
  re-measures a shape in a **port's** frame rather than its own, and the bounding box of a shape that recorded
  none (a cache entry older than the measurements, or geometry too broken to measure as it was encoded).

- **A part is a body, not a skin** (`wrappers/wrapper_common.solidify`, `brep_inspect.py`,
  `test/shell.py`): a shell is a set of faces with nothing said about which side of them is material; a solid
  is a shell declared to bound a volume. The declaration changes nothing about how the shape looks and
  everything about what can be computed from it — a boolean taken against a shell comes back with no solid in
  it — so a part handed back as a shell renders, exports and measures correctly and is wrong for interference,
  CAM, FEA and any mass in a bill of materials. cadquery and build123d both let a script return one, and a
  partType that meshes triangles builds one by nature.

  So the wrappers convert: `solidify()` replaces a **closed** shell with the solid it already bounds,
  descending into compounds (which is the case that happens, since `combine()` compounds whatever a script
  returned) and orienting the result, because a closed shell whose faces point inward would otherwise become a
  solid of negative volume — the failure `test/solidity.py` exists to report. It returns its argument
  unchanged when there is nothing to convert, so a part with no shell in it serializes to the bytes it always
  did. An **open** shell is left alone: there is no solid it bounds, and declaring one anyway would replace an
  honest surface with an invalid solid that computes nonsense. Neither is a part read from a *file* — `step`,
  `brep`: those wrappers hand over what the file holds, because the file is the authority on what the part is,
  `pc convert` round-trips through them, and a surface model somebody shipped is worth reporting rather than
  quietly changing.

  **Every part leaves its factory through one step, whatever produced it** (`Shape._validated_part`, called by
  `Shape.get_wrapped` for `kind == "part"` on a cache miss, when it has a context and the factory handed back a
  shape object; an envelope holding no geometry has nothing to judge): a native type, a mesh import, an extrusion and a
  partType plugin such as the LDraw library are all asked the same `wrapper_solidity` question, once per built
  part, with `solidify` on. A converted part comes back with the solid in place of the shell and with its
  `measurements` taken again from that solid — and only those: annotations and sections describe the same faces
  and are kept, while a shell's volume of nought is not merged under the solid's. What is still wrong is logged
  as a warning, never raised, and a sandbox that cannot answer leaves the part as it was; validating geometry is
  not a reason to stop building it. `FILE_AUTHORITY_TYPES` (`step`, `brep`) are asked with `solidify` off, for
  the reason above: judged, reported, not converted. Do not give a factory or a plugin a path around this step,
  and do not add a type to that set for any reason other than the file being the authority on the part.

  Which leaves the core to notice the ones that were not converted, and it does that **without a CAD kernel
  and without a sandbox**: `brep_inspect.py` reads the `TShapes` section of the BREP payload the core already
  holds — one record per shape, each opening with a two-letter type code — and counts the shells no solid
  references. Every solid is bounded by a shell, so "the payload contains a shell" is true of a box and says
  nothing; what is asked is whether a shell bounds anything. It counts the references rather than resolving
  them, so it never has to know which end of the record list the indices count from. `test/shell.py` is the
  check that reports the result, and it is the cheapest one `pc test` runs. Do not answer this question in a
  sandbox, and do not turn the scanner into a BREP reader: everything between a record's type code and the line
  its sub-shape list ends on is geometry, and is skipped unread.

  **No object can exclude itself from this check, nor from `degenerate` or `solidity`, and none of the three
  may be given a setting that lets it.** All three report a fact about the geometry — a surface where a body
  was meant, a part that collapsed in one direction, a solid that is inside out — and a part in that state is
  one nothing downstream can compute with, whatever it was meant to be. A check an object can turn off is a
  check that reports on the objects that did not need checking. A *kind* of object that is exempt is exempt on
  what it is and decided here: a sketch is not measured for having size in every direction, and an assembly is
  checked through its parts. What to do about a part that fails is a decision to take on that part.

- **One shape, one lock** (`Shape.locked()`): a shape is held still both while it is instantiated and while
  any file derived from it is produced. They are one question because the output path is derived from the
  shape -- `<part>.<format>` beside the package -- so two concurrent runs over one shape resolve to one path
  and interleave there, one deleting a model between the moment its owner wrote it and the moment its owner
  read it back. It is re-entrant because the operations nest (`analyze_async` holds it across
  remove-run-verify and calls `get_wrapped` and `_run_implementation_async` inside that), which a bare
  `asyncio.Lock` cannot do without waiting on itself for good. The cost is that two different outputs of one
  shape no longer overlap; PartCAD's parallelism is across shapes.
  `//builtin/scene` is the odd one out: it declares an *object* rather than a way of producing one — the
  scene a `simulate:` places its subject in when it names none of its own. It is an ordinary `assy` scene
  whose `.assy` is a Jinja2 template, and the only thing that makes it the default is that
  `simulation.DEFAULT_SCENE` names it.

  **There is deliberately no `//builtin/simulate`.** `simulation:` is a third section resolved exactly like
  the other two — a plugin is an `output.Implementation` like any other — and PartCAD implements none of it.
  A simulator is somebody's program with a release cycle of its own, so PartCAD ships the concept (the
  section, `wrappers/wrapper_simulate.py`, the runner in `simulation.py`) and a package supplies the physics:
  `partcad/partcad-sim-mujoco` is the MuJoCo one and `partcad/partcad-sim-gazebo` the Gazebo one. Each
  declares four things about one format — `import:` to read it, `export:` to write it, `simulation:` to run
  it and `open:` to look at it — because they are one piece of knowledge and a reader and a writer maintained
  apart disagree. A plugin's `format:` therefore resolves in **its own** package as well as the scene's
  (`simulation._export_scene_async` passes the plugin as the options package), which is what lets it name the
  format it implements itself.
  `simulation:` is also **not** in `output.SECTIONS`: everything that reads that tuple is asking which file
  types exist, and a simulation is not one.

  **`pc test` runs `simulate:` as its `sim` check** (`./src/partcad/test/sim.py`), through
  `simulation.run_async()` -- the code `pc sim` runs, so the two cannot come to run a simulation differently.
  Same gate as `fea`/`cfd`/`cam` (a part, an assembly or a scene that declares the section, nothing else), same verdict
  on a run that did not deliver (`ImplementationTest._verdict()`), and `simulation.SimulationConfigError` is
  what keeps a plugin or a scene that cannot be found out of the one excuse. `run_async(report=False)` is how
  the check keeps the lines that are its verdict to itself: `pc` exits non-zero on any `ERROR`, so a run that
  logged one in its own name could not then be *skipped*.

  **Every run is bounded, from both sides of the sandbox.** `simulation.run_budget()` resolves a wall-clock
  budget (`simulation.timeout` / `PC_SIMULATION_TIMEOUT` over `params: {timeout}` over the plugin's `timeout:`
  over `max(300 s, 10 s x duration)`) and hands it to the plugin as `request["timeout"]`. Inside the sandbox
  `wrappers/wrapper_simulate.py` holds the run to it -- it leads a process group, answers `timed_out` and stops
  everything the plugin started (`wrappers/process_tree.py`, stdlib only) -- because in the `docker` sandbox
  PartCAD's own child is a `docker exec` client and killing it reaches nothing in the container. Outside,
  `runtime.communicate` waits the budget plus `TIMEOUT_GRACE` and, on expiry or cancellation, takes the whole
  tree with `runtime.kill_process_tree` (a bounded run starts as a session of its own:
  `runtime.bounded_spawn_options`); a thread of its own does the same `BACKSTOP` seconds later, because on macOS
  a *stopped* child blocks the event loop inside CPython's child watcher and with it every timer. Neither kills
  by number alone: `runtime.process_owner` (pid plus start time) is checked first, because a reaped run's pid
  and group id can belong to somebody else by then. A spent budget is `SimulationTimeout`; a non-zero exit, a signal or an
  answer that is not a JSON object is `SimulationCrash` with the tail of stderr. Both reach `pc test` as a
  run that did not deliver -- a failure, with the one excuse -- and neither is cached.

  A declaration with **no `validation:`** is not run: in a walk over a package it is skipped, and asked about
  by name (`Test.NAMED`, which `_test_async` sets for an object named without a walk) it fails. `pc sim` still
  runs it and reports that it ran -- there it is how somebody looks at what a plugin reports before writing the
  condition.

  **The verdict is cached** like every check's, keyed by `SimTest.cache_key_suffix()` on the declarations
  (`validation:` included) and on each run's artifact key from `simulation.question_key_async()` -- the very
  key `run_async` looks the run up under, so a verdict cannot outlive the run it was judged on. `simulate` is
  in `shape._NON_GEOMETRIC_CONFIG_KEYS`, so editing a `validation:` changes the verdict key and nothing else,
  and is re-judged from the cached run rather than by rebuilding the object and simulating again. Only a
  verdict reached on keyed runs that came back is stored: a skip, a plugin that did not deliver, a declaration
  that does not resolve and one with no `validation:` are worked out again every time. An object reached through
  a manufacturability walk (`force_manufacturing`) is passed over, and `SimTest.test_cached` keeps that walk away
  from the cache, whose key does not carry the flag.

  **`import:` is the fourth, and the mirror image of `export:`.** It declares who turns somebody else's
  file format *into* a PartCAD object, and it is why there is no `assembly_factory_urdf.py` any more:
  `assembly_factory_imported.py` is one factory for every such type, and the reader it runs is named by the
  declaration. Everything below the reader — sandbox, tree walk, part registration, the report of what was
  dropped — was identical in the three factories that used to exist, so only the reader knows XML and only
  the reader is a plugin. `//builtin/import` ships `urdf` and nothing else; `mjcf` and `world` belong to the
  two engine plugins, beside the exporter, the `open:` entry and the simulator that share their knowledge of
  the format. The wheel carries no copy of either, so `type: world` resolves to nothing and says which
  package to name: the spelling is the full path, `type: sim-gazebo:world`.

  **A type named by its full path is resolved from the package that declared the object, not from the root.**
  `Context.get_project_from()`, not `get_project()`: a package's objects are created as part of loading it,
  so the package doing the asking is not in `ctx.projects` yet and a root-first lookup answers None for a
  dependency that is declared perfectly well. That is not a corner case — it is every object of this kind on
  the way in — and it worked on the second attempt, which is what made it look like a flake.

  Two things about it are easy to get wrong. **`import:` was the old name of `dependencies:`**, and
  `project_config.py` used to migrate it in silence — copy the value across and delete the key — which would
  now eat a reader declaration before anything could read it, then try to fetch it as a package. So the
  migration is gone and the old use is **reported** instead: `Configuration._obsolete_import_entries()`
  looks for a dependency's required `type:` (`git`/`tar`/`local`/`external`) or its transport-only keys
  (`url`, `relPath`, `revision`, `subfolder`, `onlyInRoot`, `includePaths`, `plugin`), none of
  which a reader declaration has. Do not restore the copy: guessing is what made the two ambiguous.

  **How loudly is the one thing that depends on whose package it is**, and both halves were learned the hard
  way on #637. In the *root* package it is an error and the package is broken, the way every other unreadable
  `partcad.yaml` is — that is the file the user can fix. In an *imported* one it is a warning and the package
  stays usable for everything else it declares, because `Context.import_project()` reports a broken import as
  an error of its own: `//pub/universe` in the public index uses the old spelling today, so marking it broken
  failed `pc list all -r` — every command that merely walks past it — over a section the user cannot reach,
  let alone rename. And do not make it raise in either case: an exception escaping a project factory strands
  the name in `Context._projects_being_loaded`, so every later import of it reports a recursion that is not
  happening — naming the innocent package rather than the one that failed. Both of those broke
  `Examples ... via bundle`, in that order. And an
  object type that no built-in factory is registered for is what
  `factory.instantiate()` routes here, which is also how `project.produces_own_parts()` decides, by exclusion,
  which objects materialize parts of their own: PartCAD cannot list the types in a section whose whole point
  is that it does not know what is in it.

  **`open:` is the fifth, and the only one whose implementation is not a script.** It declares the
  third-party applications `pc ide open` launches, as data: binaries per OS, a container image, the arguments
  each front end takes, and what the application can read. The logic is the same for every tool and stays in
  `partcad_client.external`, which now *builds* its `Tool` table from those declarations instead of holding
  five literals. Blender's argument builder was the one callable in that table and is now `fileArgs:`
  templates plus `formats:` — a package cannot ship a Python function into a frozen client. `formats:` is an
  ordered list: an object in none of them is converted to the first one PartCAD can write, and the edit is
  converted back over the source when the source is a file (`WRITABLE_PART_TYPES`), which is why `pc ide open`
  blocks until the application closes. An entry's `container:` goes through `partcad_utils.containers` like
  every other container, so it supports both transfer modes; `{path_repr}` (a path quoted inside a script) is
  the one template upload mode cannot honour, and Blender's own entry uses `{path}` after `--` instead.

  The subtlety is where the table is read. `pc ide open` deliberately needs **no package graph** (it is handed a
  path; the window belongs to whoever ran the command; a daemon can be remote), so the built-in entries are
  read straight off disk out of the wheel — `partcad_client` locates them with `importlib.util.find_spec`
  without importing `partcad`, the same reason `object_types` holds its own copy of PartCAD's tables. Only a
  tool a *package* declares needs the graph, and that is the `open.tools` method: the daemon says **which**
  applications exist, and never opens one. Do not add a method that opens a file.

- **A part's physics is resolved once, in core** (`physics.py`, `material.py`, `wrappers/mass_properties.py`). A
  part states what it states under `properties: physics:`; `physics.resolve()` fills in the rest, in one order
  for every reader: `density` stated, else the material's (`PHYSICS_FROM_MATERIAL` lends `mu` as `friction` and
  `density` as `density`), else — for an export only — the export's `density` parameter, else 2700; `mass` stated,
  else volume × density; `centerOfMass` stated, else the centroid; `inertia` stated, else the solid's scaled to
  that mass. `pc info` shows the result as `MassProperties` with a `source` per value, an assembly's as its
  parts' combined in its own frame; an export with `properties: true` is handed it per shape (`FACTS_KEY`,
  merged *underneath* what each shape states by `wrapper_export.properties_index()`; keyed by
  `kinematics.properties_key()`, and so is an assembly in the index that shares its full name with a part, so that
  the two stay two entries); the IDE's Build vs Buy
  table shows the same mass. No exporter knows materials or densities exist.

  **Every density is kg/m³** — a material's, a part's, an export's — so nothing converts one, and
  `mass_properties.mass_of()` is the one place a volume (mm³) and a density become a mass. A part's volume,
  mass, centre of mass and inertia are cached as one entry (`physics.DERIVED_KEY`) under `_derived_hash()`: the
  part's own cache key plus the density, where it came from, and every stated value that stands in for a
  derived one — because neither a material's density nor a stated mass is in the geometry's hash. So a CAD
  edit, a material density edit and a stated-value edit each invalidate it, and nothing else does. A miss
  derives from the geometry entry's measurements (`shape_measure.distribution()` adds the centroid and the
  unit-density inertia). `_derived_async()` is the only reader and writer; do not cache a derived mass
  beside the geometry (`-props`), whose key would not see a density edit.

  `wrappers/mass_properties.py` is the **one copy of mass-property arithmetic**: placing a part's inertia where
  a shape is placed, adding several shapes up into one body by the parallel-axis theorem, mm → m. The core
  imports it for assembly totals, and every exporter that writes a body's inertia imports it from the sandbox
  path — the URDF one here and the MJCF and SDFormat ones in the `partcad-sim-*` plugins, which require the
  PartCAD release that ships it. Do not give an exporter a copy of its own: three copies is three answers to
  where a robot balances.

  A shape names its material by a *reference* (`:aluminium`), and resolving one needs the package graph — which
  the core has and a sandbox does not. So `physics.physics_by_shape()` resolves every reference in an export
  request against the package of the shape that wrote it, which is what lets the reference be relative.

  Which reference it reads is `properties: material:`, and **a package never writes that by hand**.
  `parameters:` is what is asked of the type that produces the shape; `properties:` is what the shape turned
  out to be, and is filled in by whatever built it. For a type that accepts a `material` parameter — the
  homogeneous ones, `PartFactoryHomogen` — what it turned out to be made of is exactly what was asked for, and
  `PartFactory.record_object_type_properties()` is the instantiation code that writes it down. A `step` part
  accepts no such parameter (its file states a material per solid, and says it better), so nothing is promoted
  and the reader that read the file is what fills the property in.

- **A scene's world is a fact a simulation reads too** (`scene_world.py`, `Scene.world_facts()`): a scene
  may state its `gravity:` (a vector in **m/s^2**: physical quantities are SI, only lengths and angles are mm
  and degrees) and the `medium:` it is filled with, which is a *material* reference resolved exactly as a
  part's is. The core resolves it and hands the facts — gravity, the medium's `density` (kg/m^3) and
  `viscosity` (Pa*s) — to every file type declaring `properties: true` as `request["world"]`
  (`output.WORLD_KEY`), where they go into the engine's format unconverted. A body's buoyancy needs what it
  displaces, which is the `volume` (mm^3) `physics` resolves beside each shape's mass and
  `mass_properties.volume_of()` adds up per body, and where it is lifted from, the `centerOfVolume` (mm) that
  `mass_properties.displacement_of()` combines with it. Both default to *nothing sent*, which is
  what keeps every existing export byte-identical: the engine's own gravity, and vacuum. A bad gravity or a
  medium nothing answers to raises `scene_world.WorldError` (it is a different world, not a less detailed
  one), which `_render_one_async` reports against the scene. It lives in its own module because `shape.py`
  catches it and cannot import `scene.py`, which is an `Assembly`.

- **Where an object's ports are, and who has one** (`./src/partcad/shape_ports.py`,
  `./src/partcad/assembly_ports.py`): one answer, for every caller that asks. `shape_ports.own_ports()` is the
  per-object enumeration (a lookup: what `implements:` already placed, plus what a `map:` externalized),
  `ports_async()` adds the optional walk through an assembly's children, and `interface_index()` answers the
  same fact from the other side — which objects of a package implement an interface, for `pc search
  --interface`. All of it is plain arithmetic on `geom.Location` plus what the declarations say, so the core
  stays free of OCP, and all of it is lazy: nothing is computed while a package loads, because `pc list` does
  not ask. The index in particular is built from the *declarations* (`implements:` keys and the interfaces a
  `map:` names), so finding every part with an M3 hole does not instantiate a package.

  **An assembly is taken at its word.** Its ports are the ones it externalizes, and what is inside it is its
  own business — the same boundary `pc info`, the viewer, a `connect:` and `pc search` all see.
  `ports_async(deep=True)` (`pc render --with-internals`) is the one caller that looks inside anyway, plus
  `cae.py`, whose boundary conditions are applied to a face of one of the parts.

- **An ASSY file's root node is the assembly** (`./src/partcad/assembly_factory_assy.py`): what the file's
  top-level `links:` holds is held by the assembly directly. It used to be wrapped in a container node, which
  gave every assembly's tree two roots with one name between them — a level nobody declared, nobody can name in
  a `connect:`, and every reader of the tree had to know to ignore. A `links:` list *inside* the file is still a
  node of its own: it is addressable by `name` from a `map:`, and what it holds belongs to it. `location:` on
  the root is composed onto every item the file holds rather than onto the assembly's own placement, because
  that one is re-stamped from the declaration on every materialization — including a cache hit, where nothing
  has read the file at all.

- **A view of an assembly is an assembly** (`./src/partcad/assembly_filter.py`). Two things show part of one
  rather than the whole of it -- an instruction book's "the sub-assembly so far", and
  `pc render`/`pc export --filter` -- and both go through `derive()`: an `Assembly` whose children are handed
  over rather than read out of a declaration. Everything that works on an assembly then works on it, because it
  *is* one, and the items in it are the very items the source placed, in the very places it put them. A filter
  never re-resolves a `connect:`; what is kept stays where the assembly as a whole put it, which is the only
  reading of "part of this assembly" that is true of the thing on the bench. A derived assembly is never
  cacheable: its identity is "that assembly, minus these links", which no declaration states and no cache key
  covers, so an entry written under the source's name would be served the next time the *whole* object was
  asked for. A view keeps the source's name, placement and declaration (unless the caller replaces it), which
  is what makes a filtered render land in the file the unfiltered one would have and keeps the package's file
  types applying to it.

  **The mask itself is not here.** What a filter is, how it is written, and what naming a link with and without
  children means is `partcad_utils.assy_filter`, which also filters an ASSY *document* -- because `pc filter`
  (`./src/partcad/actions/filter.py`) writes a filtered copy of the file as a new object of the package, and a
  filtered render and that filtered copy have to hold the same parts. A link is addressed by
  `Assembly.link_name`, which is the bullet below. A link whose name was its *position* has that position
  written into the copy as a `name:`: filtering moves links, so a position would rename it and anything that
  named it would point at nothing.

  **A filter is resolved per output file, not per shape** (`Shape._render_one_async`). `filter:` is a
  `render:`/`export:` file-type field, so one object can be rendered to a filtered PNG and a whole STEP;
  `--filter` is one run's answer and overrides it, the way `--view` overrides a configured viewport. The
  subject is built at most once per mask for the length of a `render_async` call (`Filter.key`), and the field
  is held out of the implementation's parameters (`output.SELECTION_KEYS`) because PartCAD acts on it itself --
  the script is handed the smaller shape rather than the whole one and a note about it. A generated document is
  filtered the same way but not by that path (`Project._document_view_async`): a markdown bill of materials is
  built from the assembly's tree rather than from its geometry.

- **Every link has a name, and `Assembly.link_name` is it.** Its own (`name:`, or the part or assembly it
  places, which is what the ASSY factory falls back to) or, for a child nothing named, its position in its
  parent -- `link#2`, from `partcad_utils.assy_filter.synthetic_link_name`, shared with the half of the filter
  that reads the *file*. Four things ask: a `connect:` resolving its target, a `map:` naming a node, a filter
  selecting what to keep, and the **label** an assembly stamps on a child's node.

  That last one is why it has to be total. A node of a shape tree carries two names and they answer different
  questions: `name` is the *object* (`<package>:<object>`, one name on a hundred nodes placing one bolt) and
  `label` is the *link* (what tells those hundred apart). A label is therefore never a fallback to the object's
  own name -- a child nothing named is labelled by its position -- and that is what makes it the name a request
  coming *back* can carry: the IDE's 2D and Draft panels compose a filter out of the labels they are showing,
  and the daemon resolves it with `link_name`. Before this an unnamed `links:` was labelled
  `<assembly>:links`, which two of them shared and nothing could address, so there was no name to send.

  Two things followed from making it total, and both are fixes rather than side effects. A deep port is now
  named by **every** level above it (`shape_ports.child_owner`), container levels included, so two ports in two
  unnamed containers are no longer one address -- and the path the panel shows is the string
  `pc render --port` takes. And a `map:` reaches through such a container by naming it (`link#2/loose`) where
  it used to reach straight through; a declaration that did is answered with the paths that do exist, so it
  says what to write instead.

- **`map:`** (`./src/partcad/assembly_ports.py`): what an assembly externalizes of what it is made of. Two
  elements are a node and one of its ports, three are a node, an interface it implements and the instance of
  it — by ASSY *node* name, because an assembly places the same part six times. A mapped interface instance
  becomes an ordinary `InterfaceInherits` and goes through `Interface.adopt_inherit()`, the same method
  `implements:` goes through, so the port naming, the inherited freedom of movement and the ancestor walk
  happen once rather than twice. Resolution needs the assembly's tree rather than its declaration, so it is
  asynchronous (`shape_ports.prepare_async()`, a no-op for everything that declares no `map:`) and every
  reader of an object's ports calls it first. It runs *before* `ports:` and `implements:` are read, which is
  what lets an `implements:` instance sit at a mapped port (`port:` on the instance).

- **A connection is kept as its steps, and a joint is those steps with one left free**
  (`./src/partcad/joint.py`, `./src/partcad/motion.py`, `AssemblyFactoryAssy.compose_connection`): `connect:`
  places a child at *target placement · target port · half turn · target offsets · source offsets · source
  port⁻¹*, and `joint.Composition` is that product as an ordered list of fixed and free steps. The factory
  takes `AssemblyChild.location` **from** `Composition.location()` rather than computing it beside it, and the
  arithmetic is the placement's own -- one grouping, left to right, the root `location:` outermost, a free
  step at zero skipped rather than multiplied by the identity, every offset through
  `interface.movement_offset` -- so the two are equal bit for bit. Keep it that way:
  `tests/partcad/unit/test_joint_examples.py` compares them with `==` over every ASSY object under
  `examples/`, because a joint that is the placement "to within rounding" is a second placement.

  Which steps are free is `motion:` (`dof:` names parameters; `type:` implies them), resolved by
  `joint.Resolution` from the connection, else the mating, else both interfaces combined -- where two
  freedoms on one axis merge into one, ranges and values summed, and only when nothing that moves lies between
  them in a way that would turn one off the other's axis. A freedom a `type` implies uses the parameter
  along its axis or synthesizes one (`turnZ`, `angle`), which `toParams` then addresses; that is how every
  `motion:` the URDF converter wrote before it wrote the explicit `dof:` form became a joint. `AssemblyChild.joint`
  is None for a rigid attachment -- there are no fixed joints -- and for anything in a **scene**
  (`SceneFactoryAssy.KEEPS_JOINTS`), whose placements are resolved identically; a rigid attachment in an
  assembly names its target instead (`AssemblyChild.attached`, `shape_envelope.KEY_ATTACHED`), which is what welds it
  to a target that moves. A reader of another format may fill `joint` in itself: the URDF reader states each link's
  joint in the envelope form and `AssemblyFactoryImported` builds it with `joint.Joint.from_envelope`, knowing no
  format. A joint rides to an
  exporter on its child's envelope node (`shape_envelope.KEY_JOINT`, stamped by `Assembly._place` because it
  belongs to the placement, not the object); `pc info -a` lists them, and what resolving them found wrong
  (`AssemblyChild.joint_problems`) fails `pc test`'s `connect` check rather than `pc lint`, because it is a
  finding about interfaces in other packages that a lint verdict, cached on one file, would serve stale.

- **A name a file format cannot hold is transcoded, never renamed in PartCAD** (`./src/partcad/wrappers/naming.py`,
  docs/design/simulation.md section 4.4). Each format declares its own `naming.Rules` beside its exporter -- URDF's
  in `export_urdf.py`, MJCF's and SDFormat's in their plugins -- and writes every name through a `Transcoder`, the
  identity wherever the name is valid. A simulation's exporter writes the mapping as `<file>.names.json`
  (`names_file: true`), so that results come back under PartCAD's names; a scene stays `world` in PartCAD. Do not add
  a central table of formats' rules, and do not rename a PartCAD object to suit one format.
- **What becomes a rigid body is one rule, read off the tree** (`./src/partcad/wrappers/kinematics.py`,
  docs/design/simulation.md section 7): every element of a scene is a body of its own, recursively, and everything
  inside an assembly is attached to it -- welded where nothing is free, jointed where a connection keeps a degree of
  freedom, and an assembly placed in a scene one tree whose root is free. The tree carries what the rule reads, as
  the joint does: `Assembly._place` stamps each placed node's `kind` (`shape_envelope.KEY_KIND`) and a scene
  element's `fixed: true` (`KEY_FIXED`, only where true), and `Assembly.get_cache_metadata` the root's kind. Only a
  scene places a scene (`scene:`) or fixes an element to the world (`fixed:`) -- `AssemblyFactoryAssy.IS_SCENE`
  decides, a `links:` container is built as the factory's own kind so a scene's grouping stays a scene, and the
  assembly flavor of the ASSY schema (`assy_lint.assembly_schema`) forbids both the way the scene flavor forbids
  `how`. `kinematics` is pure Python beside `mass_properties`, imported by the engine plugins' exporters and the
  URDF exporter (`builtin/export/export_urdf.py`, a link per body and a joint per pair) from the sandbox path; it
  turns the shape tree every implementation is handed (`shape_tree`) into `bodies()` (parts, frames, parents, joints re-expressed for the
  body pair, combined physics) and classifies a joint's free steps (`classify()`) as one native type per physical
  joint, or `composite` (docs/design/simulation.md, section 8.13). It also re-expresses a joint for an engine whose
  zero has to be PartCAD's start (`rezeroed()`), and maps what an engine reads back into PartCAD's names and units
  (`joint_map()`, which travels in the names sidecar, and `reading()`). Do not give an exporter a copy of
  the rule, and do not make the core depend on an exporter reading it: three exporters deciding for themselves which
  parts are one body is three answers to what falls apart. A scene is also a simulation subject
  (`simulation.SUBJECT_KINDS`), placed in its world by a `scene:` link.

- **One shape, one tree, two forms** (`./src/partcad/shape_envelope.py`,
  `./src/partcad/shape_gltf.py`): every shape is a tree of nodes, and
  `Shape.get_representation(ctx, form)` is the one way to ask for it. A part or a sketch is that tree one node
  deep; an assembly is a node per thing it holds, nested as deeply as it goes, which is the very hierarchy
  `Assembly._get_shape_real()` instantiates; an interface is a node per port, with what it inherits as
  sub-assemblies (`Interface.get_representation`). Every node carries its geometry, where it sits, and what it
  declares about connections — its ports, each naming the interface instance it belongs to, and those
  instances, each naming its ports (`shape_ports.connection_metadata`). The ports are partitioned between the
  interfaces and the ones that belong to none, so a reader lists each port once.

  **The form is a parameter, not a second hierarchy.** `FORM_BREP` is what the core composes and caches because
  it is exact; `FORM_GLTF` is that same tree tessellated, for a caller that has to draw it rather than compute
  with it — a browser has no CAD kernel. One sandbox converts the whole tree (`shape_gltf.convert_async` →
  `wrappers/wrapper_gltf.py`), not one per node: starting an interpreter and importing OCP is seconds, so an
  assembly of five hundred parts converted a node at a time would cost longer than building it. Placements are
  never baked into the geometry in either form — a node's `location` places its geometry, its children *and*
  its ports, and whoever realizes or draws the tree composes them down it (`shape_envelope.placed()` is the one
  composition, shared by an assembly placing a child and an interface placing a sketch on a port).

  **How fine the tessellation is, is a budget in pixels rather than a distance in millimetres**
  (`shape_gltf.SCREEN_PIXELS`/`PIXEL_BUDGET`, applied by `shape_gltf.tolerance_for`). What decides whether a
  preview is smooth enough is how far a facet lands from the surface *on the screen*, so the linear deflection is
  the bounding box diagonal of the whole tree over a thousand pixels at half a pixel each. A part and an assembly
  then get the same answer to "smooth enough" and deflections three orders apart to reach it. Clamped at both
  ends; a caller that passes `tolerance` in mm overrides the lot, and a tree that records no size at all gets
  `NOMINAL_SIZE` rather than the floor — the floor is the finest setting there is and the least-known tree is the
  last one to spend it on.

  **That size is read, not measured** (`shape_gltf.size_of`). Every shape a wrapper returned carries the box it
  was measured in as it was built (`metadata.measurements.bbox`), so sizing a tree is arithmetic over numbers
  already in hand: no kernel, no sandbox pass, and the same box `Shape.get_bounding_box_async()` hands every
  other caller — because two ways of computing "how big is it" are two answers to it. The placements are composed
  down the tree, and a node's eight corners are placed and re-bounded rather than its two extremes moved, since a
  rotated box's extremes are not the extremes of the rotated box. Re-bounding over-states, never the reverse, so
  a tree of rotated parts is sized a little large and tessellated a little coarse: the safe direction. The
  sandbox is handed millimetres and refuses a request without them.

  Two things about it are counter-intuitive enough to be worth stating. **It has to be absolute**, and
  `build123d.export_gltf` is not: it reaches OCCT through `Shape.mesh()`, which passes `isRelative=True`, so the
  number means a fraction of *each edge* — which holds a 3 mm hole to the same fraction of 3 mm whether the
  assembly around it is 30 mm or 30 m, and is exactly the wrong scale for something looked at whole. So
  `wrapper_gltf._mesh` meshes absolutely first and `_export` tells the exporter to leave that mesh alone. And
  **the angular cap is where the cost actually is** (`DEFAULT_ANGULAR_TOLERANCE`, 0.4 rad): it is the one term
  that does not scale with the object, so it is what over-tessellates a large assembly — at 0.2 rad a 3 mm hole
  is drawn with 31 segments whether it covers two hundred pixels or two. Measured on the 8-part
  `AeroAssembly_connected`, holding the linear budget: 0.2 rad is 48304 triangles, 0.4 is 23480, 0.5 is 19752.
  `tests/partcad/unit/test_shape_gltf.py` tessellates a real cylinder to hold both facts, because nothing else
  would notice either of them breaking — the preview would simply be coarse, or enormous, at every setting.

  **One primitive per shape, not one per face** (`wrapper_gltf._write_merged`). A glTF loader makes a mesh per
  primitive and a mesh is a draw call, and OCCT writes a primitive per *face* unless asked otherwise — so a part
  of fifty faces is fifty draw calls and the eight-part `AeroAssembly_connected` was 507 of them. Asking is
  `RWGltf_CafWriter.SetMergeFaces`, and the merge is lossless: the triangles and their vertices are concatenated
  rather than welded, so the shading is identical and the payload is smaller (less JSON, fewer accessors). That
  took 507 draw calls to 8. It is also why the writer is driven here rather than through `build123d.export_gltf`,
  which builds one internally and exposes no way to set it — which costs a reach into build123d's private
  `_create_xde`, with `export_gltf` as the fallback (a preview slow to orbit beats no preview) and
  `tests/partcad/unit/test_shape_gltf.py` counting the primitives so that a release which moved it fails there
  rather than in someone's frame rate. The edges that bound no face stay a primitive of their own, because a
  line is a different draw mode from a triangle.

  **One entry per distinct geometry, however many nodes are made of it** (`shape_envelope.KEY_GEOMETRY`, with
  each node naming its entry in `KEY_GLTF_REF`). An assembly places the same bolt a hundred times; that bolt is
  tessellated once, sent once, and parsed and uploaded to the GPU once, with a hundred nodes naming it. What
  makes it possible is that a placement was never part of the geometry — which is the same property the whole
  two-form design rests on. The table is keyed by a digest of the exact BREP (`ocp_serialize.payload_digest`),
  content and not identity, because nodes arrive as separately deserialized dicts and the same shape is never
  the same object. It is the arrangement `KEY_SKETCHES` already had, one level up, and sketches now name this
  table too.

  **The sketches the ports are drawn with come with the representation** (`./src/partcad/port_sketches.py`), on
  the root node, keyed by the reference the ports already name. A port is a coordinate frame and is drawn as a
  triad; most ports also name a `sketch:`, which is the shape the connection happens across, and a viewer draws
  that too. One entry per sketch however many ports point at it — a bolt pattern is four ports and one circle —
  and it is attached *after* `get_wrapped()` rather than recorded in a node, because what a node records about a
  port is the reference: reading a declaration is a lookup and building a sketch is not.

  **The connection layer is re-stamped rather than stored.** It rides in `get_cache_metadata()`, the layer
  `apply_metadata()` puts back around every payload as it is materialized, for the reason `properties:` does: a
  cache entry is keyed on geometry and shared by every object whose geometry is identical, and two parts cut
  from one solid need not have their ports in the same places. That is also why `get_wrapped()` resolves a
  `map:` (`shape_ports.prepare_async`) before it reads the cache — a no-op for everything that declares none.

  That covers the node a shape answers for. The nodes *inside* a cached assembly carry what was written when it
  was built, exactly as their names and labels already did, and a declaration cannot have changed under them:
  `ports:`, `implements:` and `map:` are all in the shape hash — `_NON_GEOMETRIC_CONFIG_KEYS` does not name
  them, and what it does not name is hashed. Editing an *interface definition* is the gap that leaves, and it
  is the one it already left for everything else derived from one.

- **Drawing ports and interfaces** (`./src/partcad/render_overlay.py`, `./src/partcad/wrappers/stroke_text.py`):
  `pc render --with-ports`/`--with-interfaces` draws the connection metadata on top of a projection.
  `render_overlay.py` is only the drawing half — where the ports are is `shape_ports.py` above — and
  `builtin/render/render_svg.py` does the drawing, because it is the only side that knows where the camera is.
  The labels are line segments from `stroke_text.py` rather than
  an SVG `<text>` element: PNG and JPEG go through the SVG and would keep one, but DXF converts paths only, and
  real text geometry would need a font whose version this repository does not control. Two things ask for the
  overlay and neither overrides the other — the command line, and a `render:` file type declaring
  `with_ports:`/`with_interfaces:`/`with_internals:` — which is `render_overlay.effective()`, and is how
  `examples/feature_interface` keeps four such drawings checked in.

- **Parametric interfaces and ports** (`./src/partcad/expr.py`, `interface_config.py`, `interface.py`,
  `Project.get_interface`): an interface is parametrized the way a part or a sketch is —
  `m-thru;size=4,depth=3` names an instance, `Project.get_interface` builds it from the declaration as a
  template, and the shared `parse_parameterized_name`/`format_parameterized_name`/`apply_parameter_values`
  are what read the suffix, so there is one answer to what `;size=4` means. Three things about it are
  load-bearing:

  **One `parameters:` section holds two kinds, told apart by content.** It has meant the freedom of
  movement a made connection keeps (`InterfaceParameter`) since interfaces existed, and it now also holds
  the construction values a reference sets — because "the same way as for a part" is the point of the
  feature. The split is `interface_config.is_movement_parameter`, and it is safe because the two
  vocabularies do not overlap: a movement parameter is one of the six predefined names, a `[min, max,
  default]` list, or states `min`/`max`/`dir` or `type: move`/`turn`; a part's parameter states none of
  those. Every movement parameter the schema has ever accepted is caught — a custom name is *required* to
  state its `dir` — so nothing written before this changes meaning. `WithPorts` overrides both accessors:
  for a shape that section has only ever meant construction values, so none of it is movement.

  **The name is canonicalized before anything is looked up under it.** An interface's full name is what a
  mating is registered under, so `m-thru;size=4` and `m-thru;size=4.0` being two objects would be two
  halves of a connection that never find each other. `canonical_parameter_values` puts every value through
  the type it is declared as and formats it back the way `expr.format_value` writes one — which is also how
  an expression that produced it spelled it.

  **An inherited instance may restate its boundary** (`sketch:` beside the instance, read by
  `InterfaceInherits` and applied in `Interface.instantiate`). The same opening drawn differently: a slotted
  hole *is* a through hole — it inherits one, so it mates as one and keeps its port where the plain hole would
  have been — and what tells them apart is the outline and the freedom of movement. Without it a slotted hole
  is what it used to be in `//pub/std/metric/m`: an orphan with no parents, no compatibility and no mate.
  **Only an interface restates one.** The same class reads a part's or an assembly's `implements:`, and there
  `sketch:` is an error and is ignored: the outline is part of what the interface is, declared beside the
  freedom of movement that goes with it, and a part restating it could draw a slot on a through hole with no
  room to slide. A part with a slotted hole implements `m-thru-slotted`; one whose outline depends on its own
  parameters implements an interface parametrized by them (`nail-head;d=%head_d%`). The schema says the same
  with two pairs of definitions, `inherited-instances` and `implemented-instances`, identical but for that key
  (refused with a description saying what to implement instead, which `assy_lint` reports as the message).
  Keep the two in step: they once drifted apart the other way, and `implements:` refused the unnamed instance
  `""` that the loader reads, so a part that loaded failed its own `pc lint`.

  **`alias:` is what keeps a published name working.** It is spelled as inheriting exactly one interface,
  once, unnamed, at the origin — which is the shape of inheritance that leaves the ports named as the
  target names them and marks the interface a drop-in for it — plus `_adopt_alias_target()` for the handful
  of things an interface states rather than derives. `//pub/std/metric/m` is the reason it exists: eleven
  thousand enumerated names became aliases of nine parametric interfaces, with identical ports.

  Two things had to be fixed for any of it to mean anything, and both change what an *existing* package
  does — visibly, and for the better:

  * **An interface's own freedom of movement now wins over the inherited one.** The inherited declaration
    used to overwrite it, so a child could say nothing about the freedom it was given — which is what a
    slotted hole is entirely made of (it narrows `moveX` to the length of the slot). `//pub/std/metric/m`
    has always declared `moveZ: {max: length - 2}` on every `mN-screw-L`, and it had never taken effect:
    425 of its interfaces gain the movement they were written to have. Fifty of them gain a range that runs
    *backwards*, because `length - 2` is -1 for the 1mm screws its own lists name — `_check_movement_range`
    reports those and reads them as no movement, which is what they silently were before. Do not remove
    that check on the grounds that the package should be fixed instead: a bound may be an expression now,
    so the next package can write one that inverts too.
  * **`compatible_with` closes over the ancestors.** It used to be accumulated while inheriting, but
    inheriting only *creates* the parent — instantiating it is what fills in what it is in turn compatible
    with, and that had not happened yet — so the chain stopped at the first parent and an `m4-thru-3` never
    reached `m4-opening`. It is a lazy property now, and fifty of that package's interfaces reach one level
    further up than they used to. Nothing loses an entry.

  `expr.py` is the `%...%` syntax, generalized from the one `Interface.instantiate` used for inherited
  interface names. It is not Jinja2 and cannot be: `partcad.yaml` is rendered as a Jinja2 template before it
  is parsed, which is one step before the instance being asked for exists. Expressions are resolved only in
  the sections named in `Interface.EXPRESSION_SECTIONS` (`WithPorts` narrows it to `ports` and `implements`),
  because `%` is an ordinary character in a URL and in prose, and only for an object that declares
  parameters at all — a package written before this must not start reporting errors about a percent sign it
  has always had.

  **What an expression may do is a whitelist over the syntax tree, and it is wider than arithmetic.** The
  form it replaced was an unrestricted `eval`, and one published package uses it as one:
  `//pub/std/metric/cqwarehouse` names its screw interface `%size:value[1:value.index('-')]%`, reading
  "M4-0.7" as 4. So `_ALLOWED_NODES` admits indexing and attribute access, and `SAFE_ATTRIBUTES` is what
  makes the second of those safe — emptying `__builtins__` stops nothing on its own, since
  `().__class__.__base__.__subclasses__()` walks from any literal to every class in the interpreter, and the
  defence is that no name on that list leads anywhere. `format` is off it deliberately: `"{0.__class__}"
  .format(x)` traverses attributes by name at run time, which is the whole of what the list prevents. Adding
  a name to it is a decision about what a package may run at *load* time, not a convenience.

- **What a `partcad.yaml` is rendered with** (`./src/partcad/config_template.py`): the file is a Jinja2 template
  rendered to YAML before it is parsed, and this is the context. Beside the package name and the constants a CAD
  file reaches for, it carries **which PartCAD is doing the rendering** — the version whole, its three numbers,
  and `partcad_version_at_least(...)`. That is what lets one package serve two PartCADs: a package wanting a
  feature this release has and the last one did not writes both forms and picks, rather than raising its
  `partcad:` requirement and going dark for everyone who has not updated (`//pub/std/metric/m` is exactly this).

  Two things about it are deliberate. The comparison is **component-wise** — `0.8.9` is older than `0.8.77`, and
  every comparison of the strings says the opposite. And it is a **Python callable, not a Jinja2 macro**, which
  is what it looks like it should be: a macro always renders to text, so a false one comes back as the string
  `"False"`, which is not empty and so is true to `{% if %}`. A package that must also load on a PartCAD
  predating all of this guards with `partcad_version_major is defined and ...`; Jinja2's `and` short-circuits,
  so the call is never made where the name is absent.

- **Sandbox environment** (`./src/partcad/python_env.py`): importing `partcad` sweeps every `PYTHON*` variable
  out of `os.environ` and puts back only `PARTCAD_PYTHON_ENV`. Everything PartCAD spawns — the wrappers, `pip`,
  `-m venv`, conda — inherits that, which is why a sandbox interpreter runs with plain `-sOOu` rather than the
  `-I` it used to: `-I` implies `-E`, and `-E` would have made the sandbox ignore PartCAD's own
  `PYTHONHASHSEED=0` along with the user's `PYTHONPATH`. So do not reintroduce `-I`/`-E` on a sandbox command
  the environment already covers, and add anything a sandbox interpreter has to be told through the environment
  to `PARTCAD_PYTHON_ENV`, where the sweep cannot take it away again.

  Below `sandbox_versions.MIN_PYTHON_VERSION_SAFE_PATH` the environment does *not* cover it: `PYTHONSAFEPATH`
  arrived in 3.11 and an older interpreter ignores it, which would leave the directory PartCAD runs from first
  on `sys.path` for the `-m venv`/`-m pip` calls that provision a sandbox. Those calls — and only those — keep
  `-I` there, which is why `PythonRuntime` carries two flag lists (`python_flags` for a wrapper,
  `python_provisioning_flags` for a `-m` command) and picks between them in `flags_for()`. A wrapper is run by
  path, so its `sys.path[0]` is PartCAD's own `wrappers/` directory rather than anything a user writes to;
  giving it `-I` would buy no isolation and would cost it `PYTHONHASHSEED`, since `-I` implies `-E`. So do not
  collapse the two lists back into one, and do not hand `-I` to anything but a `-m` command.
  `tests/partcad/unit/test_python_env.py` asserts the outcomes (a `venv.py`/`pip.py` beside the interpreter
  never wins; a wrapper's sibling import is never shadowed by a file in PartCAD's working directory; the hash
  seed is honored on every version) on whichever version is running, so both branches are covered by the CI
  matrix rather than by a comment.

- **Loading never moves a git dependency** (`project_factory_git.py`). A copy is downloaded once, marked done
  by `.partcad.git.cloned` (written last, so a copy without it is an interrupted clone and is downloaded again),
  and then used as it is — while it is at what the dependency asks for, no network, not even the connectivity
  probe; a pin it is not at yet is fetched, which satisfies the pin rather than updating it (below). Only two
  things refetch one, both via
  `ProjectFactoryGit._wants_refresh()`: the "force auto-update" setting `forceUpdate`, and `Context.refresh_imports`,
  which `pc update` sets on its own context for one pass. There used to be a third — any copy older than a day
  was refreshed by whatever loaded it next — and it is gone on purpose: it made one commit of a package build
  differently on different days. Do not bring back a timer.

  A dependency's `revision:` is its pin and `track:` the branch the pin follows (`GitImportConfiguration`).
  Loading reads `track:` only when there is no `revision:` yet, or under `forceUpdate`, which follows the
  branch past the pin; `develIndex` is applied after both and still wins.

  A tracked dependency's copy is named after the **branch** (`<url hash>-<track>`, `import_cache_name`), not the
  commit, so there is one per branch and it is *moved* to whatever the pin says: a load finding it elsewhere
  checks the pin out (fetching that one commit if it has to — satisfying a pin, not updating), and `pc update`
  and `forceUpdate` move it in place rather than leaving a copy per commit behind. Two things follow from a copy
  that moves. A package already loaded out of it reads its files lazily, so `Context.git_checkouts` records
  which packages are built from which copy at which commit, and a second pin of the same branch that disagrees
  is kept in a commit-named copy instead (`_held_by_others`, released by `Context._drop_packages`). And
  `revision.py` caches a repository's commit by its path, which no longer changes when the commit does, so a
  move calls `revision.reset_cache()`. Other contexts are told the same through **claims**: a context built from
  the copy at a commit it is pinned to holds `<copy>.claims/<commit>.<nonce>` *locked* (`_Claim`), and a pin that
  disagrees with a claim gets a commit-named copy too (`_claimed_by_others`) — which is what keeps two daemons,
  serving two workspaces pinned to two commits of one branch, from moving the copy under each other. The OS lock
  is the claim, not the file: it goes when the process does, however it ends, and a file nobody holds locked is
  removed by whoever finds it. A context sits in reference cycles, so a context this process let go of goes on
  claiming until the cycle collector runs; a claim of this process's that is in the way is probed again after a
  `gc.collect()`, and only one in the way, because the daemon keeps a context per directory and most of them
  agree. Every context built from a copy that can move claims it — a pin, and equally a branch nobody has pinned
  yet (`pc init`'s index, when it was created offline), which reads from the commit it was downloaded at; only a
  copy named after its commit never moves. A claim only counts where it is in the way: one that agrees with what a load would do leaves that
  load on its ordinary path, the check for an interrupted download included. `forceUpdate` stopped by a claim
  follows the branch as a pin on its tip for that load (`_branch_tip`, one `ls-remote`): the claimed copy if that
  is where the branch is, a commit-named copy if not, and — only when the tip cannot be found out, offline — the
  claimed copy as it is, with a warning.
  `develIndex` gets a copy of its own (`-devel`) so that toggling it never moves the `main` one.

  Removing a copy goes through `_remove_tree`, never a bare `shutil.rmtree(..., ignore_errors=True)`, because
  Windows refuses two things that call gave up on in silence, leaving half a repository for the next load to take
  for a copy: libgit2's read-only object files, and any file a repository object still has open. A
  `pygit2.Repository` sits in a reference cycle with its own collections, so one nobody holds keeps its pack
  mapped until the *cycle collector* runs — `_remove_tree` collects first — and one somebody does hold has to be
  `.free()`d, or released from the traceback of the attempt that failed (`_release`). The `windows_deletes`
  fixture in `test_git_track.py` emulates both rules on Linux; what is a copy is decided by the
  `.partcad.git.cloned` marker, never by the directory existing. `pc update`
  (`actions/package/update.py`) is what moves a pin: it asks each tracked branch's remote where it is
  (`tracked_commit()`, an `ls-remote` — nothing downloaded), writes the commit into `revision:`, then reloads
  every package (`Context.reload_all_packages()`) and fetches them all under `refresh_imports`. It edits only
  the workspace — the root and what is reached from it through `local` dependencies and subfolders — never a
  package that was imported. The edit is one line of text (`dependency_pin.py`), not a YAML round trip, because
  `partcad.yaml` is a Jinja2 template a person wrote; where one line is not unambiguous it refuses and says what
  to write by hand. `pc add dep` writes a git dependency already pinned (`Project.add_import()`), and `pc
  install` fetches what the pins name and moves nothing. Keep `refresh_imports` on the context: a daemon shares
  one user configuration between every context it keeps warm, which is how `pc install` once left every later
  command in force-update mode.

## Schemas and linting

**Neither schema is here.** Both `partcad_utils/schema/partcad.json` (the `partcad.yaml` schema) and
`partcad_utils/schema/assy.json` (the ASSY one) live beside `partcad_utils.assy_lint`, the checker that reads
both, and ship through `[tool.setuptools.package-data]` in `pyproject.toml` and the PyInstaller spec's copy of
that directory. They are there because a client checks the file it is editing without a daemon and without a
CAD kernel: a schema under `partcad` would mean importing one to read a JSON file. `lint/all.py` registers the
checks — the names it gives them are what `pc lint -f` filters on — and `get_partcad_schema()` is the one way
in for anything that wants the configuration schema itself.

`lint/schema.py` is the *package* half of both checks: `SchemaLinting` walks a package's `partcad.yaml`,
`AssySchemaLinting` its `.assy` files, and `YamlLinting` under them is the shared body — reading the file and
handing it to `assy_lint.validate_source`. Walking a package needs the package graph, which is daemon work,
while each client checks the one file being edited in its own process (`partcad_client.lint`, reached by
`pc lint --file`). Two implementations of that check would let an editor and CI disagree about a file, so there
is one, in the package both ends already depend on. That is also why `AssySchemaLinting.get_targets` asks
`assy_lint.is_assy_file` rather than "does this file have a schema": both kinds have one now, and the looser
question would have it walk `partcad.yaml` too and report every finding twice.

A `partcad.yaml` is a Jinja2 template exactly as an ASSY file is — `ProjectLocal` renders it, `includePaths`
and all — so it goes through the same masking rather than straight to `yaml.safe_load`, and every finding
carries the line and column it came from. **A gap in the configuration schema is now a squiggle on a working
file**, not a message in a CI log nobody reads: whatever PartCAD's own tooling writes has to validate. `pc init`
writes empty (null) sections, so every section accepts null; every registered part type has to be in the
`parts` enum (`sdf` was not, and two shipped examples failed their own check because of it).

**One check is not a schema check, and could not be.** A `connect:`/`connectPorts:` names a link of the same
`links:` list, written above this node -- a relation between two parts of one document rather than the shape of
either -- and so does every name in a connection's `interferes:`. `assy_lint.check_links` is that check, run by
`validate_source` for an ASSY document (and for a scene's, whose `connect:` names a link exactly as an
assembly's does) and for nothing else. It exists because a name that is wrong is otherwise answered, much later
and much further away, with "Target part not found" and a part at the origin -- and because dropping a link
another link was connected to is the one thing `pc filter` can break, which is why that command runs this check
over the file it has just written. Like everything else here it stays quiet about what the Jinja2 mask made
unknowable: a templated name, or a loop around the `links:` list, silences the level it is on.

The **scene** schema is that same schema with `how` forbidden, derived from it by
`assy_lint.scene_schema()` rather than kept beside it as a second file — a copy is a copy that stops matching —
and the **assembly** schema is it with `scene` and `fixed` forbidden (`assy_lint.assembly_schema()`): `assy.json`
states what either may say, and neither flavor is that document unchanged.
Which of the two a given `.assy` is checked against is not a property of the file but of what points at it, so
the package half reads the declaration (exact) and each client works it out best effort: `pc lint --file` from
the `partcad.yaml` files around the file, the VS Code extension from the package contents it has already
loaded. All three lean the same way — unknown means assembly, because reading an assembly as a scene would put
a false error on correct code.

## The PartCAD IDE viewer client

`./src/partcad_ide_client` is the Python half of the socket protocol that connects `partcad` to the PartCAD
IDE extension's **Digital Thread**. It is a sibling package in the same wheel, so `pip install partcad` makes
`import partcad_ide_client` work and nothing has to install it separately.

It ships that way rather than as a distribution of its own because it was never on PyPI and every process that
could import it is a process that already imports `partcad` — `partcad.viewer` is its only importer in the
tree. Two distributions owning one import name is the thing being avoided: pip does not detect the overlap when
installing, and uninstalling either one then deletes the module out from under the other, silently. So do not
give `partcad_ide_client` a `pyproject.toml` of its own, and do not vendor a second copy into the VS Code
extension — a copy that lands first on `sys.path` shadows this one in one process and not another, leaving two
different clients in play depending on which process is asking.

The other half of the protocol is `ide/vscode/src/viewer/protocol.ts`. **A change to the wire format is
a change to both files**, and the frame layout is specified once, in
`src/partcad_ide_client/protocol.py` — that docstring is the normative description.
`ide/vscode/docs/partcad-viewer.md` walks the whole path end to end.

Two properties of the package are deliberate and easy to break:

- **No dependencies, standard library only.** It is imported into whatever interpreter is driving PartCAD, and
  depending on anything (a CAD library above all — which is exactly what depending on `ocp_vscode` did) risks
  dragging a second, differently-provisioned stack into that interpreter. Note that this is a constraint on the
  package, not on `partcad`: it is why the package can sit here without adding a single requirement.
- **It does not import `partcad`.** Geometry has already been tessellated into glTF by a PartCAD sandbox before
  it reaches here, so there is nothing to import. `partcad` imports *this*, lazily, from `partcad.viewer`.

The glTF payload codec (`encode_gltf`/`decode_gltf`) has two other implementations that have to agree with it:
`ocp_serialize.encode_gltf` in the sandbox, and `decodeGltf` in the extension. Neither can import this package,
which is why each carries its own copy; `tests/partcad/unit/test_viewer.py` and the extension's
`viewerProtocol.test.ts` are what catch a drift.

Its own tests live in `tests/partcad_ide_client`.

## Commit

`pre-commit` hooks (`dev-tools/pre-commit-config.yaml`) run `pytest`, formatting, and lint checks on commit and
are required to pass in CI before a PR can merge.
