# ide/vscode

Visual Studio Code extension providing navigation and UI for `partcad` projects. Node/TypeScript toolchain
(`npm`). Run all commands below from
this directory (`ide/vscode/`).

## The backend

**This extension is a JSON-RPC client and nothing else.** It talks to the standalone `partcad-json-rpc`
executable, translating each `partcad.*` command the UI issues into the CLI-shaped JSON-RPC method and routing
the service's notifications back under the `?/partcad/*` names the handlers listen on. See
`src/common/backend.ts` and `src/common/provision.ts`.

By default it connects over the per-workspace **daemon** (`partcad.serviceChannel: socket`): it runs
`pc daemon start`, reads the printed endpoint, and connects — a socket path on POSIX, a `\\.\pipe\...` name on
Windows, which `net.connect` takes either way. `partcad.serviceChannel: stdio` runs a dedicated process over
stdin/stdout instead. "Restart PartCAD"/reset runs `pc daemon stop` to tear down the warm context.

**What `pc daemon start` prints is checked before it is connected to** (`daemonEndpointIn`). A `pc` with no
daemon for its platform answers with a *sentence* saying so — on stdout, with a zero exit status, which is
where the endpoint goes. Windows `pc` did exactly that until the daemon there was un-gated, and "the first
non-empty line" handed that sentence to `net.connect`: ENOENT about a filename made of English, and a window
with no backend at all. So an answer that is neither an absolute path nor a pipe name means "this installation
has no daemon", and the backend falls back to `stdio` — one service for this window, cold rather than shared,
which is a downgrade and not a failure. It says so in the terminal view, because a user who asked for `socket`
did not get it.

The connect itself retries for a few seconds (`connectEndpoint`). The endpoint `pc` prints is live by the time
it prints it, on both platforms; the retry covers the difference in *how* — a POSIX daemon binds and listens
before it forks, so an early client queues, while the Windows daemon is a separate process whose pipe does not
exist until it is ready, and connecting to a pipe that is not there fails rather than waits.

Where the executable comes from is `resolveServicePath`: the `partcad.servicePath` setting, then an existing
standalone install (`$XDG_DATA_HOME/partcad`, or `%LOCALAPPDATA%\PartCAD` on Windows — `install.sh` does not
run there, so naming the POSIX locations in the report only sent Windows users looking somewhere nothing
installs to), then the extension's own download directory, then the download directory it had before it
changed publisher (see "The marketplace identity" below), then `~/.local/bin` where there is one, then a
plain `PATH` lookup (quoted entries and all: Windows quotes a `PATH` entry containing a space, and the quotes
are not part of the directory's name). That last one is why a user with their own Python needs no download at all -- `pip install partcad`
puts `partcad-json-rpc` on the `PATH`.

**The dialog appears if and only if none of those six resolves.** Anything found is used, silently: an
installation the user already has is never a question. When there is none, `ensureServiceExecutable` offers
exactly the two things that can produce one -- `Download` the standalone bundle, or `Find installed PartCAD` and
point at an environment that already has PartCAD in it. There is no third option, and in particular no silent
one: the second button used to read "Use Python instead" and select a `python` backend that had been deleted
with the language server, so the one choice a user with their own Python would obviously make gave them no
backend at all and a log line nobody reads.

`Find installed PartCAD` asks for a **directory**, not the executable -- someone who just ran `pip install partcad`
knows where their environment is, not what the service is called. `serviceUnder` accepts the environment's
`bin` (`Scripts` on Windows), which is what is asked for because that is the directory holding `pc` and
`partcad` beside the service, and also the environment root, which is the obvious near-miss. The answer is
written to `partcad.servicePath`; the setting is `machine`-scoped, so `Global` is the only target it can go to,
which is right for something that describes this machine rather than a workspace.

`ensureServiceExecutable` returns a `ServiceResolution` rather than a path because of what that write does:
`partcad.servicePath` is in `checkIfConfigurationChanged`, so setting it *is* a restart. The `restarting` case
tells `restartBackend` to stand down and let the configuration change drive the single start -- nothing
serialises two of them, and returning a path here as well would race one start against the other.

**The `PATH` it searches is the extension host's, which is not the one the user's terminal has.** The host
inherits `PATH` from whatever launched VS Code -- a desktop launcher, the Dock, an unactivated shell -- so a
`partcad-json-rpc` inside a Python virtual environment is found only when VS Code itself was started from that
environment. Nothing here consults the interpreter `ms-python.python` has selected; `partcad.interpreter` went
with the language server. So "PartCAD is installed and the extension still offers to download it" is expected
rather than a bug -- and `Find installed PartCAD` is what it is for. This is also why `resolveServicePath` takes an
optional `searched` array and why `reportNoService` in `backend.ts` prints it: the message a user gets has to
name the `PATH` it consulted, or it sends them looking in the environment they can see.

**The `PartCAD` terminal view shows what the CLI shows, and does not render it itself.** Colours, level
prefixes and the multi-line progress footer are a state machine (`partcad_utils.logging_ansi_terminal`) that
the CLI runs on its own side, replaying the structured events a daemon forwards. This extension cannot: it is
TypeScript, and a second implementation of that footer is a second thing to keep correct. So the *service*
runs it -- `partcad_utils.logging_ansi_render.AnsiEventRenderer`, one instance per connection -- and sends what
it drew.

`JsonRpcBackend` asks for that with `log.mode {"ansi": true}` as soon as it connects. The service then sends
`?/partcad/terminal` (base64, written into the pty verbatim) **instead of** `?/partcad/log`, not as well as:
the two carry the same information. The `?/partcad/log` handler stays as the fallback for a service too old to
answer `log.mode`, and `rendersLogs` says which of the two is live.

Three things about it are load-bearing:

- **One renderer per connection.** The footer is drawn by moving the cursor back over the lines it last wrote,
  so two clients sharing a renderer would each erase lines from the other's terminal. The daemon is shared;
  the renderer is not. `log.mode` is handled in the transport beside `daemon.stop`, because the dispatcher only
  sees `(request, session)` and the session belongs to every client at once.
- **`\n` becomes `\r\n` on arrival.** The renderer emits bare newlines, which is right for the CLI -- a real
  terminal has ONLCR on. A pseudoterminal does not, so untranslated output walks off to the right. The
  translation belongs here, where the destination is known.
- **Nothing else may write while it is rendering.** An unrendered line inserted between the renderer's writes
  desynchronises the footer's cursor arithmetic. So `?/partcad/error`/`warn` are not echoed when `rendersLogs`
  is set (the renderer already prints them, in colour), and what remains -- the no-backend reports below --
  happens only when there is no renderer running.

**A backend that never starts reports itself in the `PartCAD` terminal view**, not only in the output channel.
Everything else in that view arrives over the backend's own `?/partcad/terminal` and `?/partcad/log`
notifications, which is precisely what a missing backend cannot send, so `terminal.ts` carries a
`setTerminalWriter`/`writeTerminal` pair: `extension.ts` registers the writer (it creates the views, with the
reopen/popup settings and the context) and `common/backend.ts` calls it without importing `extension.ts`, which
would be an import cycle. Before that, no backend meant a silent, inert window.

Those reports -- no service, a service that would not start, one that kept dying -- pass an `alert`. An alert
opens a view even when the user has closed every one, and says itself in an error popup when none was open:
nothing else on screen would say why PartCAD stopped. See "The `PartCAD` terminal views" below.

**Daemon handling is the CLI's, not the extension's.** Which socket serves which workspace, whether anything
is answering on it, and how to stop it and wait are `partcad_client`, reached by running `pc`. A second copy of
those rules in TypeScript is a copy that can disagree, and a disagreement means the extension quietly starting
a daemon of its own beside the one `pc` is using. What stays in Node is the socket transport itself, because a
live notification connection cannot be shelled out.

### There used to be a second backend

A Python language server under `bundled/tool`, with PartCAD's dependencies vendored into `bundled/libs`,
selected by `partcad.backend: "python"`. It is gone, along with the setting, `partcad.interpreter`,
`partcad.importStrategy`, the `ms-python.python` dependency, and 3,361 lines of Python.

It was never the only way to do anything: the JSON-RPC backend registers every command the language server did,
and two more. And it could not have worked where it was shipped -- `bundled/libs` held compiled wheels
(`pygit2`, `aiohttp` and `cffi` publish no `py3-none-any` wheel at all) while the `.vsix` is built once, on
Linux, for one CPython. Do not reintroduce a second backend; extend this one.

## The Digital Thread

End-to-end walkthrough, with the data flow diagram and what each tab is:
[docs/partcad-viewer.md](./docs/partcad-viewer.md).

`src/viewer/` is the extension-host half of the viewer: a TCP server on a constant loopback port
(`PartcadViewerServer`) that `partcad` processes connect to, and the webview panel that displays what they
send (`PartcadViewer`). `src/webview/` is what runs *inside* that webview: `viewer.ts` is the shell,
`scene.ts` the three.js renderer, and one module per tab beside it.

**A show can say which tab to open on** (`tab`, from `pc ide view --design-2d` and its siblings; `KEY_TAB` in
`src/partcad_ide_client/protocol.py`). It rides on the show rather than arriving as a message of its own,
because the show is what decides which tabs apply, and because two windows sharing the viewer port share it
by connection. The renderer hands it to the strips as a *request* (`Tabs.request`) before they are rebuilt for
the object: a request is opened even when the tab is `secondary`, waits through a rebuild that does not offer
it yet (Build and Buy, until Build vs Buy has answered), and is forgotten once opened or when the next show
asks for nothing. The user also withdraws it, by clicking a tab or by a change on the tab they are on that
rebuilds the strip (`keepCurrent`, a Build vs Buy switch): what they did wins over what a command line asked for
earlier, so a request waiting for Buy does not pull them off the tab they are working in. `TAB_GROUPS` in `src/webview/messages.ts` is the list of what can be asked for, and a Python
test holds it to `VIEWER_TABS` on the other side.

**The panel is a strip of tabs over one object, not a canvas.** 3D first, then FEA and CFD for a part, then
Manufacturing: what is built and what is bought, how to build it, the bill of materials, where to buy it and the
assembly instructions. In front of all of them, for an object that declares `requirements:`, a Requirements tab:
asked about on every show (`requirements.check`, a declaration read) and verified when opened, by the same code
as `pc test -f requirements`. Only the 3D
view comes over the viewer protocol; every other tab is a question about `<package>:<name>` that the renderer
cannot ask itself -- the CSP forbids network access and the daemon is behind the host's JSON-RPC connection --
so it asks the host (`fetchTab`) and the host answers (`tabData`), on first look. Which is why the show
message carries the object's **package**: without it the panel offers the 3D view alone.

**The 3D tab is a control pane and a canvas, over one node tree.** What arrives over the protocol is a single
tree of nodes, whatever is being shown -- a part or a sketch is that tree one node deep, an assembly is a node
per thing it holds, an interface is a node per port -- and it is PartCAD's own hierarchy with glTF at the nodes
instead of BREP (`partcad/shape_envelope.py`). **So there is no per-kind case on this side and no depth-1
special case**: `scene.ts` walks the tree into `THREE.Group`s and `tree.ts` walks it into rows, agreeing on what
the drawables are called through `nodes.ts` and nothing else. Do not derive a structure here or bake a placement
into geometry -- a node's `location` places its geometry, its children and its ports, and the renderer composes
them down the tree (see "Coordinates and units" in
[docs/partcad-viewer.md](./docs/partcad-viewer.md), which also says what starts out checked).

**The 2D and Draft tabs have the same control pane, and it means something different there.** All three Design
tabs are asking one question of one tree -- which of these do I want to see? -- so all three use `tree.ts`, and
the differences are two options on it (`TreeOptions`): Draft lists no ports or interfaces, because a
dimensioned drawing is of the solid, and both render tabs fix the root's box ticked, because the object is what
is being rendered. What the boxes *do* differs because those two cannot switch anything off on a stage: the
picture is made by PartCAD and arrives as a file. So the ticked boxes resolve to a **filter** sent with the
render (`Tree.filter()` -- the mask `pc render --filter` takes, read by `partcad_utils.assy_filter`) and, on
the 2D tab, to which port overlays to draw (`Tree.overlay()` -- `--with-ports`/`--with-interfaces`/
`--with-internals`). Do not add a second tree widget for that: a row's meaning would then have two definitions.

**The three tabs share one answer** (`Selection`). What somebody wants to look at is a property of the object,
not of the tab it is being looked at on, so the state lives outside the widgets and each of them reads and
writes it: switching tabs shows the same selection, and a box cleared on one is cleared on the others. That the
Draft tab lists no ports is exactly why it cannot live in the widgets -- rebuilt from Draft's own rows, a port
cleared on the 2D tab would come back ticked. It follows that **what starts out ticked cannot differ between
the tabs**, so the 2D tab's first picture of an object that declares ports has them drawn on it: the panel and
the picture agreeing is worth more than a cleaner default.

Three details there are load-bearing. A node row is named by its **label**, which is the name the assembly
addresses that link by, down to a child nothing named (`link#2`) -- see `Assembly.link_name`; there is no row
the mask cannot name and no second rule for one. A port row carries the name PartCAD *reports* it under (the
path of labels, then the port), which is `shape_ports.qualify` on the other side -- the two sides compose the
same string from the same parts, which is what lets the panel ask for one port of one placement
(`pc render --port`). And an *empty* mask is not "keep everything": the filter language has no spelling for
"keep nothing", so `filterIsEmpty` tells that case apart and the pane says there is nothing to render rather
than sending a mask PartCAD would read as the whole object. A change to a box is debounced before the render is
asked for (`SELECTION_SETTLE_MS`), because a drawing is minutes of daemon time and choosing what to look at is
several clicks; the two render tabs then forget their picture and the one on screen asks again, which is how a
box ticked on the 3D view reaches them.

**The geometry itself arrives once per distinct shape, not once per node.** PartCAD sends it as a table on the
root keyed by a digest of the exact shape, with every node naming an entry (`gltfRef`), so an assembly that
places one bolt a hundred times is a hundred nodes and one piece of geometry. `PartcadViewer.inflate` therefore
decompresses the table rather than the tree, `scene.ts` parses each entry once into a `THREE.Group`, and each
node after the first adds a `clone()` of it -- fresh `Object3D`s over the same `BufferGeometry` and the one
shared `MeshPhongMaterial`, which is one upload and one material for the lot while every node keeps its own
object to switch on and off. It is the arrangement the port sketches already had ("cloned per port, so forty
holes drawn with one circle upload one geometry"), applied to the model. Two consequences worth keeping: the
loading readout is a fraction of the *table* (`totalSize`), because counting a shared shape once per node would
report a download that never happened; and nothing may dispose a parsed group that has been handed out, since
its geometry is the geometry of every clone of it.

None of those tabs is implemented here. `bom`, `assembly.guide`, `supply.quote` and `cae.analyze` are the CLI's
own operations (`pc bom`, the book `pc render -t html` writes, the cart `pc supply quote` fills, the analysis
`pc cae fea`/`pc cae cfd` runs), asked for as data rather
than as a file -- the same rule as everywhere else in this extension: extend the one backend, do not
reimplement it in TypeScript.

The FEA and CFD tabs are the one pair that *acts* rather than asks: selecting one runs a solver. The field
over the model names which -- pre-filled from `cae.defaults` (the user configuration's
`caeFeaImplementation`/`caeCfdImplementation`) and filled in even when the run failed, which is when a user
most needs to see what was tried. The model is drawn by its extension, because the format is the
implementation's choice: a mesh gets an orbit camera, a picture pans and zooms. See
[docs/partcad-viewer.md](./docs/partcad-viewer.md).

Four things about it are load-bearing:

- **Two webpack bundles, two tsconfigs.** The extension host is CommonJS; the webview is a browser context and
  three.js ships its addons as ES modules only, so `src/webview` compiles under `tsconfig.webview.json` (and is
  excluded from `tsconfig.json`). `npm run compile` builds both.
- **The listen options are platform-specific, and getting that wrong is silent.** `SO_REUSEPORT` lets a second
  window share the viewer port instead of losing it, and libuv implements it only where it also load-balances
  — Linux and the BSDs — *rejecting the bind with ENOTSUP everywhere else*. Asking for it unconditionally
  therefore took the whole viewer down on Windows and macOS, with one line in the output channel to show for
  it, from the moment VS Code shipped a Node new enough to pass the option through (22.12). There is nothing
  to ask for instead on Windows: `listen` has no `reuseAddr` (that is `dgram`, for UDP), and libuv sets
  neither `SO_REUSEADDR` nor `SO_EXCLUSIVEADDRUSE` for a TCP server there on purpose, since `SO_REUSEADDR` on
  Windows means "take a port another process is using". `listenOptions` decides, and the second window falls
  back to the `EADDRINUSE` branch, which is what it always did.
- **The panel's CSP forbids network access.** Geometry arrives over `postMessage` and is parsed from memory;
  three.js is bundled rather than loaded from a CDN. Do not add an asset that is fetched at runtime — that is
  what rules out drei's `environment` presets, and why the renderer uses `RoomEnvironment`.
- **The wire format is shared with Python.** `src/viewer/protocol.ts` mirrors
  `src/partcad_ide_client/protocol.py`, which is the normative description. Changing one
  means changing both. `src/test/suite/viewerProtocol.test.ts` covers this side.
- **A crash on the way up used to look like an idle panel.** The HTML's resting state is "Nothing to display
  yet.", and the renderer replaces it once geometry arrives -- so a renderer that never ran left exactly the
  message shown before the user has asked for anything. `scene.ts` builds its `THREE.WebGLRenderer` at module
  scope, and that constructor throws in a window with no WebGL, during `viewer.ts`'s *import*: no `message`
  handler registered, no `ready` posted, nothing logged. `host.ts` traps `error`/`unhandledrejection` and puts
  the reason in the overlay; it lives there because every webview module imports it, and a module's
  dependencies are evaluated before its own body, so it is installed before anything can throw.
- **No WebGL is not no 3D view -- in any pane.** Every pane that draws in 3D -- the 3D view (`scene.ts`) and the
  FEA/CFD result model (`cae.ts`) -- gets what it draws with from `surface.ts` (`createSurface`), the first of
  three backends the window can give: WebGL; else WebGPU, through three's `WebGPURenderer` (loaded from
  `three/webgpu` only then, which shares `three.core.js` with the `three` build the scene is made of, so the
  same meshes and materials draw); else the **canvas painter** (`painter.ts`), which projects the scene on the
  CPU with three's `Projector` and fills its triangles into a 2D canvas, far to near. Do not construct a
  `THREE.WebGLRenderer` in a pane: that is how the FEA tab drew nothing in a window where the 3D view coped. A
  backend that failed once is not tried again by the next pane, so a window says why once. The painter is
  2.5-3.5x faster than three's `SVGRenderer` on the same scene (one DOM element per triangle is what that
  costs), which is why it is ours rather than theirs. It is a different renderer with different limits, and
  the panes say where they differ: it draws only when something changed (`invalidate`), not every frame; the
  model does not turn on its own until asked; it lights with ambient, hemisphere and directional lights only,
  without three's 1/pi Lambert term and with no environment map, so the 3D view replaces its rig with a light
  that moves with the camera and the analysis view gives it the same flat light over pi; a solid (and an
  analysis result, which is the surface of a part) is drawn front-side only, because it orders whole triangles
  by depth and a far face would stripe the near one; a face is one colour, so a result's per-vertex colours
  are taken at its first vertex, after `cae.ts` has rewritten glTF's RGBA or normalised-integer `COLOR_0` as
  the three floats `Projector` reads; it converts colours to sRGB itself, as the GPU renderers do on the way
  out; and a model over `SOFTWARE_TRIANGLE_BUDGET` triangles is drawn as boxes (`boxesOf`) while the camera
  moves. The advice for getting the GPU back (`host.ts` `noWebGL`) is the tooltip of every such pane's
  `softwareNotice`. A WebGPU adapter is asked for before three is, because `WebGPURenderer` falls back to
  WebGL 2 by itself, and there is none. **An adapter and a successful `init()` do not make a WebGPU renderer
  that draws**, so `surface.ts` draws a probe first -- each kind of material the panes make, lines, an
  environment map, inside a validation error scope -- and takes the painter if it fails. Two windows fail it
  for real: a Chromium older than the WebGPU specification three is written against (r185 sends a texture
  view's `swizzle` as a string, and Chromium 141 refuses that from every `createView`, so every frame threw),
  and one whose GPU process cannot present a WebGPU canvas, where the device is lost on the first frame. A lost
  device also rejects every error scope three left open with no `catch`, which reached `host.ts`'s trap and
  wrote "The Digital Thread hit an error" over a view that was drawing fine; `watchDevice` turns those into
  "the device was lost". Where WebGL fails because the GPU process did, WebGPU usually fails with it -
  Chromium then offers the API and no adapter, not even the software fallback one - so the painter is what
  such a window ends up with; WebGPU is for a GPU WebGL could not use and WebGPU can.
- **Nothing is escaped on its way into a pane.** What the tabs display is text out of a package's
  configuration -- a description, a part name, a supplier's answer, the name of a port -- so every pane builds
  its DOM node by node through `src/webview/dom.ts` rather than assigning `innerHTML`. `textContent` cannot be
  talked into being markup; a template literal can.
- **The webview has one test suite, and it brings its own DOM.** `src/test/suite/viewerTree.test.ts` runs in the
  extension host, which has none, so it installs a stand-in with the handful of methods `dom.ts` asks of an
  element and exercises what the pane lists for a node tree, which of it ends up drawn, what the render tabs'
  boxes resolve to, and that the three tabs share one answer, through it. Note that `tsc -p .` does **not**
  compile `src/webview` as a root, so a type error reachable only from there -- a use before declaration, say
  -- surfaces in `npm run compile` (webpack, over `tsconfig.webview.json`) and nowhere else. Run both. That is as far as this reaches: a test
  that imported anything else of `src/webview` would import `scene.ts`, which builds its renderer as it
  loads. Note that `tsconfig.json` excludes `src/webview` as a *root* -- an imported module is still compiled,
  which is why this works, and why what it imports has to be free of three.js.

**A SpaceMouse arrives by two roads, and `src/webview/spacemouse.ts` is the only file that knows what its axes
mean.** On Windows and macOS the renderer reads it through the Gamepad API; on Linux the extension host reads
spacenavd's socket (`src/viewer/spacenav.ts`) and forwards the events untouched, because spacenavd holds the
device and Chromium sees nothing of it. Do not convert spacenavd's values in the host: the test that holds the
two roads to each other (`src/test/suite/viewerSpaceMouse.test.ts`) can only do so while both conversions sit
side by side. The extension host runs where the workspace is, so in this repository's dev container spacenavd
is the host's, bound in by `.devcontainer/host-sockets-init.sh`. Every 3D pane attaches to the panel's one
`spaceMouse` as a `Navigable` and drives it from its own render loop, and only the pane on screen moves; a new
3D pane that does not attach is one the device does nothing in. See "A SpaceMouse" in
[docs/partcad-viewer.md](./docs/partcad-viewer.md).

Geometry reaches the viewer already tessellated: `partcad` renders to binary glTF in a sandbox and sends it
compressed, so the extension never needs a CAD library. It used to hand live OCP objects to the third-party
`OCP CAD Viewer` extension, which is why that dependency is gone.

## Opening a workspace from the command line

`pc ide workspace <directory>` opens the folder in an editor and asks for the PartCAD workbench in the window that
opens it. An editor's command line cannot run a command in a window, so the CLI writes a request first --
`~/.partcad/ide/requests/<random>.json`, `{"folder", "view": "workbench", "requested"}`, renamed into place --
and `src/workbenchRequest.ts` takes it: on activation, whenever the window gains focus (an existing window of
that folder is brought to the front, and activates nothing), and when the workspace's folders change. The
window whose workspace is the folder runs `workbench.view.extension.partcad-container` and deletes the file;
an expired one (`REQUEST_TTL_S`, five minutes) is deleted unread by whoever sees it. It is registered before
the trust gate on purpose: in Restricted Mode the workbench is where the Explorer asks for trust. The Python
half is `partcad_client.ide`, and the file's layout is written down in both.

## What the Explorer says while starting

The `partcadExplorer` welcome views in `package.json` are the only status the user sees before the tree
appears, and they are selected by context keys rather than by anything that inspects the backend. Which key
means what:

- **`partcad.serviceMissing`** -- `restartBackend` found nothing to connect to. Set there because that is the
  only place that can tell "no service" from "a service that has not answered yet".
- **`partcad.activated`** -- a service is connected and `activate` has been *sent*. Not that it succeeded.
- **`partcad.installed`** -- `?/partcad/loaded` (or `packageLoaded`) came back, so activation finished.
- **`partcad.failed`** -- something along the way reported failure, `activateFailed` included.

So `activated && !installed` is "connected, activation in flight or failed" -- and activation runs the health
checks, so on a cold install it is in flight for a while. That state used to be one welcome view reading
**"PartCAD v0.8.15 is not found."**, which was wrong in both halves of it: the extension was talking to a
PartCAD, and the version it named was the version it was talking to. A user who had just pointed the extension
at their own environment was told it had not been found. It is two views now, split on `partcad.failed` --
starting, and did-not-finish-starting -- and neither claims anything about what is installed. The version came
out of the text with it, along with its entry in `dev-tools/bumpversion.toml`.

`?/partcad/error` and `?/partcad/warn` go to the `PartCAD` terminal view as well as to a popup, because the
Explorer can only say that activation did not finish; the reason arrives on those notifications, and a popup
is dismissed or (with `partcad.showNotifications` turned down) never shown.

Nothing emits `INSTALLED`/`INSTALL_FAILED` any more -- `events.py` still defines them and `extension.ts` still
listens -- so `partcad.installed` moves only through `loaded`/`packageLoaded`. Do not read the name as "the
PartCAD Python module is installed"; that meaning belonged to the language server, along with the no-op
`partcad.install` command.

## Workspace trust

**The extension supports untrusted workspaces as `limited`, and in one it is visible, inert, and asks.**
Opening a package runs the code its parts are written in and fetches what it imports, so in Restricted Mode
nothing starts -- no service, no terminal, no PATH change, no file of the package read -- and a notification
asks for trust (`explainUntrusted`, not awaited) as soon as the extension activates. `activate` is `activateWhenTrusted` (`src/common/trust.ts`),
which runs the real activation, `activateTrusted`, at once in a trusted folder and on
`onDidGrantWorkspaceTrust` otherwise -- trust granted mid-session starts PartCAD without a reload. It is never
*revoked* mid-session: the editor reloads the window for that.

It used to declare them unsupported (`false`), and the editor then removes everything an extension contributes
from a restricted window -- the activity bar icon, the views, the settings -- so a user who dismissed the trust
dialog had no PartCAD at all and nothing saying why.

**All of this is for a regular VS Code.** The PartCAD IDE starts with workspace trust turned off
(`--disable-workspace-trust`, added by its entry point), so `vscode.workspace.isTrusted` is always true there
and none of it runs; `ide/standalone/README.md` has why. Do not detect "am I in the IDE" here to skip the
check: the IDE's answer belongs to the IDE, and an extension that trusted folders on its own say-so in some
editors would be one a workspace could talk into it.

What the untrusted window shows, and why each is there:

- **A `viewsWelcome` on `!isWorkspaceTrusted`**, with a button running `workbench.trust.manage`. The Explorer has
  no data provider until activation, so its welcome content is what shows. The "being initialized" message is
  the only other one whose `when` needs no key that `activateTrusted` sets, so it carries `isWorkspaceTrusted`
  too -- otherwise both would show at once. A new welcome message must keep that true; `trust.test.ts` checks.
- **`when: isWorkspaceTrusted` on the two webview panes.** Nothing registers their providers until trust, and
  an unresolved webview view is an empty pane forever.
- **A stand-in for every contributed command.** The palette and the view menus list them whether or not they
  are registered, and running an unregistered one is "command not found". The stand-ins explain and offer the
  trust editor, and are disposed before `activateTrusted` registers the real ones under the same ids. The
  terminal panel's "+" menu is the same for the `PartCAD` profile it lists, so that has a stand-in too: a view
  saying why it shows nothing.

**A failure to start after trust is granted is reported** (`reportTrustedActivationFailure`: the log, an error
message, `partcad.failed`). On the trusted path the editor reports a rejected `activate`; this one happens in
an event listener after activation returned, with the stand-ins already gone, so nothing else would.

`npm test` opens its workspace trusted and nothing a test does can grant trust to a window, so the untrusted
path and the grant are tested through `activateWhenTrusted`'s options (`isTrusted`, `onDidGrantTrust`,
`explain`, `onFailure`).

## Installing a package's dependencies

`partcad.installPackage` runs the daemon's `install` operation - the PartCAD counterpart of `npm install`: it
downloads every imported package and prepares every sketch, part and assembly (see `pc install`). It runs
against whatever `partcad.packagePath` resolves to. "The first time" is remembered per config path in the
workspace state, so reopening the window is never a second download.

**`partcad.installOnOpen` defaults to `"false"`, so this does not happen on its own.** It used to, once per
workspace, and the cost was not what "install the dependencies" suggests: `install` begins with
`ctx.get_all_packages()` under `force_update = True`, which is the whole transitive closure re-fetched --
measured at 101 packages and 268 seconds for `examples/`, with a warm cache and force-update *off*, 62 of them
the entire `//pub` index. Then every object is asked for its cache key, which resolves aliases, enriches and
links and runs plugin scripts (the LDraw ones to their 180 second deadline). And `socket_server.py` holds one
`_dispatch_lock` around every dispatch, so for the duration nothing else the window asks for - expanding a
node, opening a part, linting - is served. The tree appears, because `install` runs after `packageLoaded`, and
then the window is frozen behind it.

Turning it on is a reasonable thing for a user to do; doing it to them on first open is not.

Do not confuse it with `partcad.install`, which is a no-op: it bootstrapped the PartCAD Python module for the
backend that no longer exists, and survives only so an old command binding does not break.

## Updating PartCAD

"Update PartCAD" (`partcad.update`) updates the PartCAD installation — `pc upgrade`, not `pc update`, which
refetches a package's imports and is "Reload the package" (`partcad.refresh`) here. The extension implements
none of it, which is the point: there is one upgrader, `partcad_client.selfupdate`, and the extension
reaches it the same way a user would.

It spawns `<bundle>/pc --no-ansi upgrade` in the workspace folder and streams it to the output channel
(`updateServiceBundle` in `src/common/provision.ts`). `pc` looks up the latest release and, only if something
newer exists, stops every daemon running on the machine, waits for them, installs the new bundle beside the
running one, and removes every superseded bundle. The extension then reconnects, at the new path — which is why
`resolveServicePath` picks the newest `<root>/<version>/` directory rather than a fixed one, and how the
extension detects that anything happened: the resolved path moves. If there is no `pc` beside the service, or
it is too old to know the option, the extension downloads the release itself (`downloadLatest`), the same path
a first install takes.

The other direction is a refusal, and deliberately so: `pc upgrade` run *inside* a bundle this extension
downloaded errors out and says to update the extension instead. See "The tools on the terminal PATH" below for
how it knows.

The Explorer's "install"/"needs to be updated" buttons (`partcad.startInstall`) route to `partcad.update` too:
installing what is missing and updating what is stale is one operation, and a user should not have to know
which of the two they are asking for.

The standalone layout is shared with `install.sh` and `pc upgrade`: `<install-dir>/<version>/{pc,partcad,
partcad-json-rpc}`. Installing side by side (rather than over the running copy) is what lets the bundle replace
itself while it is executing, and is required on Windows, where deleting a running executable fails outright.
No superseded bundle is left behind: the idle ones go immediately, and the one the updater is running out of is
removed by a detached reaper once that process exits.

## The tools on the terminal PATH

`src/common/terminalPath.ts` prepends the directory holding `pc`/`partcad` to `PATH` for terminals opened in
this window, through `context.environmentVariableCollection`, so `pc` works without the user installing
anything or editing a shell profile. `partcad.addToolsToTerminalPath` (default true) turns it off.

The directory is `path.dirname()` of whatever `resolveServicePath` found. A standalone bundle is
`<install-dir>/<version>/{pc,partcad,partcad-json-rpc}`, one directory holding all three, so the executable the
extension already resolved names it -- and one `PATH` entry covers every entry point.

It also sets `PARTCAD_MANAGED_BY=vscode-extension` on those terminals. `partcad_client.selfupdate` reads it and
makes `pc upgrade` refuse: a bundle this extension downloaded is replaced by updating the extension, and
upgrading from inside it would install a second copy the extension does not know about. Keep the two constants
in step; `selfupdate.py` is the only reader.

Four things about it are deliberate:

- **Unconditional.** No `getScoped` per workspace folder, no check for a PartCAD package in the workspace, and
  no check for what is on `PATH` already. An activated extension means the tools belong in every terminal of
  the window. Some of `resolveServicePath`'s fallbacks (`~/.local/bin`, a plain PATH lookup) resolve to a
  directory that is on PATH anyway; prepending it again is inert.
- **Not persistent.** `collection.persistent = false`, against the default. `pc upgrade` installs beside the
  running bundle and deletes every superseded one, so a restored collection would put a deleted directory on
  the PATH of every terminal until activation caught up. Re-applying on each activation cannot go stale.
- **`clear()` before `prepend()`.** `prepend` appends to what the collection holds, so refreshing after an
  upgrade would otherwise leave both the old and the new directory on PATH, oldest first.
- **The trailing `path.delimiter`** is part of the prepended value, not decoration.

Refreshed on activation, on `?/partcad/scriptsPath`, after `updateServiceBundle`, and on any configuration
change (cheap and idempotent, so it does not work out which setting moved).

Inside the PartCAD IDE this runs alongside `ide/standalone/bootstrap/extension.js`, which does the same
thing for the tools bundled in the application. Both prepend, and `bootstrap` points `partcad.servicePath` at
the bundled service, which `resolveServicePath` honours first -- so the two agree on the directory and the only
effect is that it appears on `PATH` twice.

The `PartCAD` views `src/terminal.ts` creates are not touched by any of this: they are pseudoterminals, log
surfaces with a no-op `handleInput` and no environment to inherit. The shell it opens to their left is an
ordinary terminal and gets the tools like any other.

## The `PartCAD` terminal views

`src/terminal.ts`, `PartcadTerminals`. What the user sees, and why each part is built the way it is:

- **Nothing opens until there is output.** The first write in a window opens a view, split to the right of a
  shell named `Shell`: one already open, or a new one running the user's default profile with `SHELL_BANNER` at
  the top (`TerminalOptions.message` is written into the view, not sent to the shell). Activation runs the health
  checks as a logged process, which ends in a `DONE:` line at the least, so the pair appears at every start, as
  the single terminal used to.
- **The banner's logo is block characters, not ASCII.** `LOGO` is the logo as pixel art, a quarter of a cell
  per letter, in its own tones: at 13 columns a line drawing of it reads as a dotted outline, and this reads as
  the logo. Two facts hold it up. A cell can show two colours -- a quadrant character's foreground and
  background -- which a test checks of every 2x2 square. And the terminal leaves block characters (U+2500 to
  U+259F) out of `terminal.integrated.minimumContrastRatio`, which recolours text against the theme's background
  and would otherwise turn a light theme's yellow into brown, tone by tone. A cell's main tone is its
  *background* wherever it can be: the terminal draws block characters itself only with GPU acceleration on,
  and from the font they leave seams between cells, where a background never does. Lines stay within 65
  columns -- the logo, a gap and the longest sentence -- for the shell's half of a split panel.
- **Closing a view keeps it closed.** `partcad.reopenTerminal` defaults to `false` now; it used to reopen the
  view on the next log line, which is fighting the user. "PartCAD: Show Terminal" and the `PartCAD` entry in the
  terminal panel's "+" menu (`contributes.terminal.profiles`, `partcad.terminal`) open one again. In Restricted
  Mode that entry is a stand-in from `trust.ts`, for the same reason the commands have one.
- **Any number of views, all written to, each with its own pseudoterminal.** `open()` writes the greeting and
  the history into the view it opens, so a shared emitter would write them into every view at once.
- **A replay buffer** (`ReplayBuffer`) is what makes closing harmless: a view opened later starts with the
  history. It is needed for the very first view as well, because VS Code subscribes to `onDidWrite` only just
  before it calls `open()` -- anything sent earlier is dropped. It keeps the renderer's output in the chunks it
  arrives in, one per log event, laid out as [erase the last footer][log lines][footer], and relies on that
  layout twice: the oldest chunks are dropped whole, and the first one kept loses its erase run, which would
  otherwise eat into the history above it; and a footer-only chunk following another is merged into it, so a
  long build does not push the history out with progress ticks. A change to how
  `partcad_utils.logging_ansi_terminal` frames its output is a change to `ReplayBuffer`.
- **`isTransient` decides nothing for a view.** The editor sets it on every extension pseudoterminal itself,
  and never saves one across a reload in any case. The shell *is* saved and restored, so after a reload the
  view is created again beside it.
- **A shell restored late replaces the one made in its place.** The view can need a shell before VS Code has
  restored the old one, and then a second one is made. For `REVIVE_WINDOW_MS` (5s) after making a shell, a
  restored one replaces it: the new shell and its view are closed and a view opens beside the restored shell,
  filled from the replay -- unless a command has already been run in the new shell, which is then kept. That is
  `window.onDidStartTerminalShellExecution`, looked up at run time because it is newer than `engines.vscode`;
  not `Terminal.state.isInteractedWith`, which any input sets, including the terminal's own answers to a
  starting shell's queries -- on Windows (conpty) every new shell had it set before its first prompt. It is
  polled, not an event: a restored terminal is announced to extensions before its name is set again, and
  nothing tells them when that happens.
- **The shell is the user's.** Nothing disposes it with the views or on deactivation; the case above is the
  only one in which the extension closes a shell.

## YAML diagnostics

Two documents are checked: `.assy` files and a package's `partcad.yaml`. Neither is YAML -- each is a Jinja2
template that renders to YAML and then has to match a schema. (`.world` files are claimed for `xml`, only to give
them the PartCAD icon: a Gazebo world *is* XML, so the editor's own XML support is the whole of what it needs --
there is no PartCAD check for one, and none is wanted.) `src/PartcadLint.ts` checks the open document
(debounced on edit, immediately on open/save) through the `partcad.lintFile` command and publishes the answer
into a `partcad` diagnostic collection. Diagnostics are published per document URI, so they need no language id;
`isPackageConfigDocument` matches the basename rather than the extension, because a `parts.yaml` next door is
somebody's own file and not a package configuration.

**An `.assy` file is `partcad-assy`, and `partcad.yaml` is `partcad-yaml` -- neither is `yaml`.** Both used to
be `yaml`, for the highlighting, and then every YAML extension had an opinion about them too, and reported the
template's tags -- every `{% for %}` line -- as YAML errors, beside the real findings, in a template that renders
fine. A YAML extension picks its documents by language id, so a language of its own is what keeps them off. That
also takes away the `[yaml]` editor defaults -- spaces, two of them, and suggestions inside values, which `- pa`
completion needs -- so `configurationDefaults` restates them for both ids, and the `partcad:` snippet provider in
`extension.ts` is registered for both. With that, a YAML extension had nothing left to do for a PartCAD user, and
neither the repository nor the PartCAD IDE recommends one any more.

**Both languages share one grammar, `syntaxes/partcad-yaml.tmLanguage.json` (`source.partcad.yaml`): the
editor's YAML grammar with Jinja2 injected on top.** Everything subtle about it is about the YAML grammar
underneath, which is not one grammar: VS Code's own (`source.yaml`, which picks between a full YAML 1.2 grammar
and an "embedded" one), unless somebody has installed a YAML extension that brings a `source.yaml` of its own and
so replaces it -- Red Hat's, with an older one, is the one people have. Each `comment` in the file says why a rule
is there; the short version:

* It includes `source.yaml` and nothing more specific. VS Code's versioned grammars refer back to `source.yaml`
  for some of their rules, so naming `source.yaml.1.2` directly turned every comment into an error wherever such an
  extension is installed.
* The YAML grammar tracks indentation with `while` rules, checked at the start of every line before any
  injection: a `{% endfor %}` indented less than the YAML around it had its characters eaten as indentation
  errors, and a block scalar never ended. So the outermost rule (`meta.template.jinja`) is a `while` of its own
  that takes the `{% %}`/`{# #}` tags a line starts with: the whole line when that is all it has, so YAML sees the
  blank line it renders to, and otherwise up to the last of them, so YAML reads the rest from there.
* Inside `[...]` and `{...}` the 1.2 grammar is strict about what follows what, and never sees what `{{ x }}`
  stands for: `[{{ x }}, {{ y }}, 0]` flagged its second `,`, and `-{{ x }}` its `-`. A second injection takes
  those before YAML does. It has to be declared first, since injections that tie are tried in declaration
  order.

`src/test/suite/partcadYaml.test.ts` asks the running editor for its tokens (`_workbench.captureSyntaxTokens`,
which VS Code's own colorization tests read) for a fixture of the placements above and for every templated
`.assy`/`partcad.yaml` under `examples/` and `src/partcad/`, and fails on any `invalid.illegal` token or any tag
that is not read as Jinja. So it checks VS Code's own YAML grammar, under `npm test` and in the `bundledIde` run
alike; one an installed YAML extension substitutes is not covered by any run, and a change to the grammar has to be
tried with Red Hat's installed by hand. A window that was open while the grammar changed keeps the old one until it
is reloaded.

The checker's half of the same problem lives in `partcad_utils.assy_lint`: it renders the template with the
values PartCAD would (see "The checker is" below) and maps every finding back to the template, and only masks a file
nothing declares. In the mask, a `{{ expr }}` alone on its line counts as lines of YAML (blank, and unknown both
as a value and as keys) rather than as a value -- the filler scalar it used to become turned the next key into
"mapping values are not allowed here" -- and macro, `call` and block-`set` bodies are masked whole.

**The `Lint` settings section** is `partcad.lint.enabled`, `partcad.lint.includePaths` and
`partcad.lint.extraParams`, read per document by `PartcadLint.lintOptions` (they are resource settings, and
include paths resolve against the document's workspace folder) and handed to `partcad.lintFile`. The backend
passes them on as `pc`'s global `--extra-param` -- the option every command overrides parameters with, so the
check renders with what a render would -- and `pc lint`'s own `--include-path`. Only entries in the
`object.parameter=value` shape that `--extra-param` parses are passed: a `pc` that cannot parse its own arguments
checks nothing. Changing any of them re-checks every open file.

**The check never reaches the daemon**, and there is no RPC method for it. It is the client's own file --
usually one the editor has not saved -- and it needs no package graph, no CAD runtime and no loaded context, so
sending it would mean shipping the buffer across a wire to have it read back, and would leave the editor silent
exactly when the package fails to load *because* of the file being typed into. `JsonRpcBackend.lintFile`
answers it by running `pc --no-ansi lint --file <path> --stdin --json`, feeding the buffer on stdin. Same
reasoning as `pc daemon stop`: defer to the CLI rather than keep a second copy here.

An **ASSY** file a **scene** points at is checked against the same schema with `how` forbidden, and which of the
two a given file is is not a property of the file. (A `partcad.yaml` has no flavor -- nothing points at a
package configuration -- so `flavorOf` returns nothing for one and none is sent.) `PartcadLint.flavorOf` answers it from the package contents
the Explorer has already loaded -- the declaration itself -- and leaves the question to `pc lint` for a file no
loaded package mentions. Both sides go through `pathKey` (`common/paths.ts`) rather than comparing paths as
strings: `Uri.fsPath` lower-cases a Windows drive letter and PartCAD does not, so the editor's spelling of a
document and the daemon's spelling of the same file never matched there and every scene was checked as an
assembly. The daemon answers the same question with `os.path.samefile`; this one has to work on a buffer that
has never been saved, so it normalises instead of asking the filesystem. Both lean the same way when they cannot tell: unknown means assembly, because reading
an assembly as a scene would put a false error on correct code.

The checker is `partcad_utils.assy_lint` (schemas: `src/partcad_utils/schema/assy.json` and
`partcad.json`), shared with the daemon-side package lint so an editor and CI cannot disagree about a file. It
renders the file with the values PartCAD would -- an ASSY file's parameters at the defaults its declaration
gives them, worked out from the `partcad.yaml` files above it by `partcad_utils.lint_context` -- and
`partcad_utils.template_render` keeps the way back from every rendered character to the template, so each
finding still lands on the line and column the user wrote. A file nothing declares has no known values and is
masked instead; findings that depend on what the mask hid are dropped rather than guessed. The same discovery
says whether the file is marked `manufacturable: true`, which adds the rules for what is to be made. Change the
schema or the message wording there, not here -- and remember that a gap in the configuration schema is now a
squiggle on a working file, so anything PartCAD's own tooling writes has to validate.

## Spelling

Code Spell Checker checks every language unless told otherwise (`cSpell.enabledFileTypes` is `{"*": true}`), so
giving the two documents languages of their own did not take them away from it, and every key that is not English
-- `assy`, `connectPorts`, `countPerSku` -- was underlined in every package. `src/spelling.ts` hands it
`cspell/cspell-ext.json` through the API it exports for exactly this, `registerConfig`, which is also how its own
language dictionaries arrive. That file defines one dictionary, `cspell/partcad.txt`, and gives it to
`partcad-assy` and `partcad-yaml` and nothing else, so a `desc:` is still checked against English and no other
file loses a typo to it. It is not a `cSpell.*` default in `package.json`: that could only carry the words inline,
and a user's own `cSpell.languageSettings` would replace it rather than add to it. The spell checker is optional
both ways -- not a dependency, registered whenever it turns up, and nothing breaks when its API does not answer.

`cspell/partcad.txt` is generated (`npm run cspell-dictionary`, `cspell/generate.js`) from
`src/partcad_utils/schema/assy.json` and `partcad.json`: every property name and every string an `enum` or a
`const` allows, cut where the spell checker cuts (at case changes and non-letters), plus a short list in the
script of what no schema spells -- tool and format names, Jinja2's end tags. A key a schema gains is a word
packages are about to be written in, so rerun it with any schema change; `src/test/suite/spelling.test.ts` fails
while the file is out of date. The generated file is what is packaged (`.vscodeignore` leaves the script out),
since the `.vsix` carries no schemas to generate from.

The same test file asks a real spell checker which words it flags in a `partcad.yaml`, and skips under a plain
`npm test`, which does not install one. To run it:

```bash
npm run pretest && PARTCAD_TEST_WITH_CSPELL=streetsidesoftware.code-spell-checker npx vscode-test --label withSpellChecker
```

`installExtensions` puts it in `.vscode-test/extensions`, which every configuration shares, so from then on
`npm test` runs that test too rather than skipping it. Take it out again with the downloaded editor's
`bin/code --extensions-dir .vscode-test/extensions --uninstall-extension streetsidesoftware.code-spell-checker`.

## Opening a file in a third-party application

The Explorer's per-item **"Open in > ..."** menu hands the item's file to `partcad.openExternal`, which runs
`pc --no-ansi ide open --with <tool> [--type <type>] [--use-docker] [--docker-image <image>] <path> --json`. Five
applications, each offered for the objects it can actually open:

| Menu entry | Command | Shown for |
| --- | --- | --- |
| FreeCAD | `partcad.openInFreeCAD` | parts and assemblies |
| Blender | `partcad.openInBlender` | parts and assemblies |
| Gazebo | `partcad.openInGazebo` | scenes of type `world` (`viewItem == sceneWorld`) |
| MuJoCo | `partcad.openInMuJoCo` | scenes of type `mjcf` (`viewItem == sceneMjcf`) |
| KiCad | `partcad.openInKiCad` | parts of type `kicad` (`viewItem == partKicad`) |

The narrow ones are why `PartcadItem` gives those objects a context value of their own: `viewItem` is one
string compared exactly, so "a scene Gazebo can open" and "a part KiCad can open" have to *be* separate
values. Each is then added back to every other clause that names their kind, because a world scene is a scene
everywhere else and a KiCad part is a part.

Each engine is offered for its own scene format and not the other's, which is not an oversight: `pc ide open`
converts no scene. MJCF and SDFormat are written by the engines' plugin packages, and a file handed to
`pc ide open` has no package around it to reach either through, so a world sent to MuJoCo is refused with
the export that writes MJCF. Offering the entry there would offer a refusal.

**It never reaches the daemon, and there is no RPC method for it** -- a stronger version of the rule
`pc lint --file` follows. A daemon can be remote: "open this in FreeCAD" sent to one would put a window on
somebody else's screen, on a machine that may have no display at all, and the path would name a file only the
client has. So the finding of the application, the container and the X forwarding are `partcad_client.external`
and nothing here reimplements them; what stays in TypeScript is the menu, the setting, and showing the failure.

The failure is the interesting half. `pc ide open` prints its reason as JSON *and* exits non-zero, so
`JsonRpcBackend.openExternal` reads the JSON with `allowFailure` and throws the message as it came --
`PartcadExplorer.openWith` shows it verbatim in the error dialog's detail. That message is the answer the user
needs (which X server to install and what to allow, or how to let PartCAD use a container); replacing it with
"the command failed" would throw away the whole point of the command.

What is handed over is `config.item_path`, not the item's `itemPath`. The tree sets `itemPath` only for the
types this editor can *edit* -- the scripts -- so a STEP or BREP part, which is exactly what another CAD
application is for, has none; `config.item_path` is the file the daemon reported for the object either way.
The menu is therefore on the same items as "Export" and an object that has no file of its own says so when it
is picked, rather than being silently missing from the menu.

The object's declared `config.type` is handed over too, as `--type`, and it is the *only* other thing this
tree contributes. It is there because a file name does not always say what it holds -- a `.py` is a CadQuery
script, a build123d one or an SDF one -- and Blender reads meshes and nothing else, so a part that is not
already one is converted to STL before it is opened. Which types are meshes
(`partcad_client.object_types`), whether this one needs converting, where the mesh goes and who does the
converting are all decided by `pc ide open`; nothing here branches on the type, and nothing here knows that
Blender is the application it matters for.

And no more than that is decided here. A `kicad` part's file is the STEP KiCad's command line writes out of
the board; the board is the `.kicad_pro` beside it, and swapping one for the other is a fact about KiCad that
lives in `external.TOOLS` (`Tool.companions`), not in this tree. Same for how Gazebo is launched: `gz sim`,
`ign gazebo` and `gazebo` are three front ends of one application and `Tool.binary_args` is where that is
written down -- as is Blender being handed `--python-expr` rather than a file name, because the only file
Blender *opens* is a `.blend`.

`partcad.open.useDocker` and `partcad.open.dockerImage` are read here, on the way to that command line, rather
than being worked out anywhere in Python: PartCAD decides *how* to run the application, and the settings only
say what it is allowed to do. Adding a second application is a command, a menu entry and a row in
`external.TOOLS` -- no new branch in this extension.

**`pc ide state` is answered here** (`src/ideState.ts`). The viewer server (`PartcadViewerServer`) receives
the `state` message and asks a provider wired in `extension.ts`, which collects three entries: the
Explorer's selection straight off the `TreeView`, the Inspector's object (`PartcadInspector.state`, which asks
its webview what each field holds *now* and falls back to the applied values when the view cannot answer), and
the Digital Thread's (`PartcadViewer.state`, which asks its webview). The webview's half is `src/webview/state.ts`
plus `collectState`/`answerState` in `viewer.ts`: the tab and sub-tab on screen, what every strip offers, each
sub-tab's `pc render --filter` and selections (its tree's rows from `Tree.rows()`, the 2D overlays, and every
control in its pane from `controlValues`), and a screenshot. The screenshot is the pane's DOM drawn through an
SVG `<foreignObject>` onto a canvas, with each `<canvas>` replaced by an image of itself first -- the 3D view's
through `scene.screenshot()`, which draws and reads in one task because its WebGL buffer is not preserved. The
host writes the PNG into `os.tmpdir()` (`writeScreenshot`); the webview has no filesystem. A view that fails
reports an error in its own entry and the rest still answers. The panel is never brought forward to be
photographed. Nothing about any of this is visible to `vscode-test` without a display; the pure parts
(`explorerEntry`, `inspectorState`, `writeScreenshot`, `Tree.rows`) and the socket round trip are tested in
`ideState.test.ts` and `viewerTree.test.ts`.

**The editor waits while the application is open** (`src/editingBanner.ts`). `pc ide open` blocks until the
application closes and then brings the edit back into the package, so until it returns the package is in two
places at once. `openWith` therefore shows a banner over the editor area, sets the `partcad.editingExternally`
context key, and every command in `package.json` carries `"enablement": "!partcad.editingExternally"` --
`editingBanner.test.ts` fails for a command added without it. Only one application at a time: a second "Open
in..." is refused while one is open. "Stop waiting" (in the banner, or Cancel on the progress notification)
aborts the `pc ide open` child through an `AbortController` and leaves the application running -- it was started
in a session of its own -- and says that what is saved from then on is not brought back. What `pc ide open --json`
reports (`changed`, `writtenBack`, `edited`) is turned into one sentence by `outcomeMessage`, which is pure so
it is tested without a window. There is no timeout on that call, deliberately: somebody may edit for an hour.

## Setup

```bash
npm install
```

## Test and validate changes

```bash
npm run lint                                # eslint on src/**/*.ts
npm run format-check                        # prettier check
npm test                                    # vscode-test extension tests (xvfb-run -a npm test on Linux)
```

`npm test` downloads a VS Code and opens `sampleWorkspace/` in it -- a directory that has to exist and has to
be free of PartCAD content, or the extension activates through `workspaceContains` before the test that
activates it. `.vscode-test.js` sets `PARTCAD_EXTENSION_NO_PROMPTS=1`, because a runner has no PartCAD
installed and activation would otherwise put up the modal "shall I download it?" dialog that nothing headless
can answer. Both of those are why the suite now runs on `windows-latest` in `npm-test.yml`, where it had been
disabled as "for some reason doesn't run test suite".

**Windows and macOS behaviour is tested from the Linux runner where it can be.** A platform-specific decision
belongs in a pure function that takes the platform (`pathKey`, `listenOptions`, `daemonEndpointIn`,
`resolveServicePath`'s `searched` list) so a test can ask what it would do somewhere else. Everything in this
file that starts "on Windows" was shipped broken at some point precisely because nothing asked.

### Testing against the IDE, not against VS Code

**`npm test` downloads stock VS Code, so it says nothing about the editor that ships.** That is the right
default -- what it checks is this extension's logic -- but it is blind to everything the PartCAD IDE puts
around it: the built-in extension set, `product.json`'s `configurationDefaults` (`partcad.backend`,
`partcad.serviceChannel`), the bootstrap extension and the first-start workspace it creates, the embedded
`partcad-json-rpc` the extension is meant to find without a dialog, and the branded application shell itself.
Nothing here started that editor until #609, and it could not start at all on macOS for two releases without a
single test in this repository noticing.

So a change to any of those is tested against a **built** IDE. `.vscode-test.js` offers a second
configuration, `bundledIde`, when `PARTCAD_IDE_PATH` names one. What it wants is the application *binary* --
not the bundle, and not the `bin/` launcher:

```bash
# macOS. The executable's name belongs to the editor this was built from --
# VSCodium's, today -- so it is read rather than written down here.
app="$HOME/Applications/PartCAD IDE.app"
export PARTCAD_IDE_PATH="$app/Contents/MacOS/$(plutil -extract CFBundleExecutable raw "$app/Contents/Info.plist")"

# Linux. Resolved through the launcher `install.sh` symlinks, rather than
# assuming the layout it owns.
app="$(dirname "$(dirname "$(readlink -f "$HOME/partcad-bin/partcad-ide")")")"
export PARTCAD_IDE_PATH="$app/partcad-ide"

npm run pretest && npx vscode-test --label bundledIde        # xvfb-run -a ... on Linux
```

```powershell
# Windows. There is no `install.sh` there -- the setup program puts the
# application here -- and `fromPath` is read by Node, so it wants a native path.
$env:PARTCAD_IDE_PATH = Join-Path $env:LOCALAPPDATA "Programs\PartCAD IDE\partcad-ide.exe"
npm run pretest; npx vscode-test --label bundledIde
```

The first two are the ones `build-ide-standalone.yml` uses, and neither hardcodes a name it can ask for. The
first version of that step wrote `Contents/MacOS/VSCodium` and was right about today's bundle and wrong in
principle; `ide/standalone/AGENTS.md` has which plist field means what.

**CI runs this on macOS and Linux only.** The `install` job drops Windows from its matrix -- there is no shell
installer there, and `install-windows` runs the setup program instead -- so the Windows form above is the one
to use by hand and the one nothing checks. Do not reach it by running the Bash recipe under Git Bash: there is
no `~/partcad-bin/partcad-ide` to resolve, and if there were, `readlink -f` answers with an MSYS path
(`/c/...`) that `useInstallation.fromPath` cannot use.

Unset, the configuration is not offered and `npm test` is unchanged, so nobody needs a bundle to work here.
`.github/workflows/build-ide-standalone.yml` sets it after installing a build, which is the only place it runs
in CI -- `ide/standalone/AGENTS.md` has how that bundle is made.

Three things the bundled run needs, all of which have already cost a debugging session:

- **`PARTCAD_EXTENSION_NO_PROMPTS=1` still applies.** The IDE *does* carry a service, so the download dialog is
  not the risk it is under stock VS Code -- but a prompt of any kind is still something a headless run cannot
  answer, and on Windows an unanswered modal keeps the window from ever closing.
- **The built IDE already contains a released copy of this extension.** `--extensionDevelopmentPath`, which the
  runner passes, is what makes the checkout win. A test asserting on extension *version* rather than behaviour
  will read whichever copy it happened to get.
- **A first `Display` provisions a conda environment**, which takes about three minutes on a clean machine
  against roughly two seconds warm. A test that exercises geometry needs a warmed `~/.partcad/conda` or a
  budget that admits the cold path; the 60s Mocha timeout is sized for activation, not for that.

### The end-to-end test, in a real editor

`tests/ide/test_ide_e2e.py` (Python, beside the other `pc` tests) does what a person does: `pc ide install` a
`.vsix` into an editor with a profile of its own, `pc ide workspace examples`, `pc ide view --design-3d` the logo
assembly and `pc ide view --analysis-fea` the cantilever -- and fails on anything going wrong on the way: an
`ERROR` from the daemon in what `pc` printed, an `[error]` the extension logged, a console error or uncaught
exception in any PartCAD webview, the viewer's error overlay, or an error in the FEA pane. It drives the editor
over the DevTools protocol (`--remote-debugging-port`) and attaches to the webviews' own targets, because the
viewer is an out-of-process frame inside another one, which Playwright's `connect_over_cdp` does not reach.

It is off unless `PC_TEST_IDE_E2E=1`; `PC_TEST_IDE_EDITOR` and `PC_TEST_IDE_VSIX` name the editor and the package,
or VSCodium is downloaded (Linux, macOS or Windows) and the package built from this directory. It starts an
`Xvfb` on Linux where there is no display. The FEA step needs the CalculiX solver: from the plugin's image where a
container runtime is running, or `ccx` on the `PATH` (`apt install calculix-ccx`) where it is not. Where there is
neither and no runtime could have run the image, the test accepts the excuse `pc test` accepts
(`CaeTest._verdict`) and checks the rest of the tab.

CI runs it as `IDE (end to end)` in `.github/workflows/test.yml`, gated on the `ide-e2e` output of
`changed-scopes` (a change to `ide/`, to the source or to its dependencies): on `ubuntu-latest` for a pull
request, and on `windows-latest` and `macos-latest` as well from the merge queue up (`OSES_IDE_E2E`). The job
fails on a skip, since running this one test is all it is for, and uploads the editor's logs and the viewer's
screenshots (`PC_TEST_IDE_ARTIFACTS`).

That test is also why `surface.ts` asks a throwaway canvas for WebGL 2 before three is asked: three
`console.error`s three lines and then throws in a window with no GPU, and a missing GPU is a fallback to the
painter, not an error.

## Build / package

```bash
npm ci && npm run vsce-package   # builds partcad.vsix (vscode:prepublish runs webpack)
code --install-extension partcad.vsix
```

That is the whole build, everywhere: `.github/workflows/vsix.yml` runs it once and attaches
`partcad-<version>.vsix` to the GitHub release, and `ide/standalone/build.sh` runs it for the copy that
ships inside the PartCAD IDE. It used to be `nox --session build_package`, which had to populate `bundled/libs`
first and had to run per platform because those were compiled wheels. There is no Python in this package any
more, so one build serves every platform.

The same workflow packages `ide/vscode-shim` beside it, into the same artifact and onto the same release. That
is the marketplace entry this extension used to be published under -- see "The marketplace identity" below.

## The icon

`resources/logo.svg` is the PartCAD mark, and it is the only drawing here: the activity bar container, the
three views in it, the `partcad.yaml`/`.assy`/`.world` file icons and the `PartCAD` terminal view all point at
that one file, and so does the PartCAD IDE, which renders its application icons from it
(`ide/standalone/tools/make_icons.py`). `resources/logo_128x128.png` is the same mark rasterized, which is
what `package.json`'s `icon` has to be: `vsce` refuses an SVG there.

That `.png` is the one image in this repository that is **not** in Git LFS, and it has to stay that way. No
checkout in `.github/workflows` asks for LFS, `vsce package` copies the file into the `.vsix` without looking
at it, and the result is an extension whose icon is a 130-byte pointer -- which is exactly what every release
up to 0.8.35 shipped. `.gitattributes` names the file and says so.

The corollary is that this package should carry **no other image**. `docs/` used to hold `image1.png` and
`image2.png`, which were byte-identical copies of `docs/source/images/vscode1.png` and `vscode2.png` at the
repository root -- nothing referenced them (`README.md` links the originals by absolute URL, which is what the
marketplace needs), and they shipped in every `.vsix` as two more LFS pointers. They are gone. An image this
package genuinely needs has to be added the way the icon is: named in `.gitattributes` and kept out of LFS, or
it arrives as text.

After changing `partcad` core code while developing through this extension, click "Restart PartCAD" in the
PartCAD `Context` view (or restart VS Code) to pick up the change.

## The marketplace identity

This extension is `PartCAD.partcad-official`. It used to be `OpenVMP.partcad`, and a publisher is half of an
extension's identity, so that was a different extension rather than an earlier name for this one -- nothing
in the marketplace or the editor carries an installation across.

The `name` is the other half, and it is `partcad-official` rather than `partcad` because the marketplace does
not let two publishers share an extension name: `partcad` belongs to the old entry, which `ide/vscode-shim`
still publishes to, so this one cannot also be called that. `displayName` is unaffected -- the extension is
"PartCAD: Digital Thread" in the Extensions view, which a user searching for "PartCAD" finds. Do not try `partcad_official`:
`vsce` validates both `name` and `publisher` against `/^[a-z0-9][a-z0-9\-]*$/i` and rejects an underscore.

Three things follow, and all are already done:

* **The old entry is not abandoned.** `ide/vscode-shim` is published to it: no code, one
  `extensionDependencies` on `PartCAD.partcad-official`, so an installation of the old entry updates into a
  dependency on this one. Its `AGENTS.md` has the rest, including the ordering rule -- **this extension has to
  be published first**, because the editor fails an install whose dependency cannot be resolved.

* **`globalStorageUri` moved with the identity, twice.** It is named after the extension, so bundles this
  extension had downloaded into `globalStorage/openvmp.partcad/` (before the publisher moved) or
  `globalStorage/partcad.partcad/` (before the name did) are not in
  `globalStorage/partcad.partcad-official/` where it now looks. `resolveServicePath` reads both old roots as
  fallbacks (`legacyBundleRoots` in `src/common/provision.ts`) -- without them, an upgrade across a move tells
  a user whose PartCAD is sitting right there that none was found, and downloads a second copy. They are
  fallbacks and not the download target: new bundles go to the current root, so the old directories are
  superseded rather than kept in step.

* **The id is not a prefix of `PartCAD.partcad-ide-bootstrap` any more.** It was, and the checks in
  `.github/workflows/build-ide-standalone.yml` that look for the extension inside a built IDE still require a
  version digit after the id rather than a bare `*`, which is what kept them from accepting the bootstrap
  extension in its place. Keep that -- it costs nothing and the next id may share a prefix again.

The extension id appears outside this package too -- `ide/standalone/build.sh` installs it into the IDE by id,
`.github/workflows/build-ide-standalone.yml` checks the built IDE contains it (matched case-insensitively,
since the editor lowercases the directory it installs into), `.devcontainer/devcontainer.json` lists it,
`ide/standalone/tests/` asserts on it, and `src/test/suite/extension.test.ts` activates the extension by it.
`ide/vscode-shim/package.json` depends on it. Those are the places to change together if it ever moves again,
along with the install instructions in `README.md`, `docs/source/installation.rst` and
`ai-agents/common/skills/setup/SKILL.md`.

## Commit

`pre-commit` hooks (`dev-tools/pre-commit-config.yaml`) run `eslint` on changed `.ts` files and are required to
pass in CI before a PR can merge.
