git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

CLAUDE.md (107.6K)

   1 # CLAUDE.md
   2 
   3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
   4 
   5 ## What this is
   6 
   7 `cce-designer` is a node-based procedural 3D design app (Houdini-style) for the cce
   8 desktop environment: a node graph is evaluated into geometry — native Rust
   9 operators, plus a Rhai wrangle for per-element scripting — and displayed in a 3D viewport with both a raster pass and a path-traced
  10 (RT) preview mode.
  11 
  12 This crate is one member of the multi-repo `cce` Cargo workspace; workspace-wide rules
  13 (multi-repo layout, no `[workspace.dependencies]`, shared `../target/`) live in
  14 `../cce-compositor/WORKSPACE.md`. This directory is its own git repository.
  15 
  16 ## Build, test, run
  17 
  18 ```sh
  19 cargo build -p cce-designer            # from the workspace root
  20 cargo run  -p cce-designer             # needs a Wayland session (cce or any compositor)
  21 cargo test -p cce-designer             # all tests live in src/main.rs's tests module
  22 cargo test -p cce-designer test_keyboard_shortcut_system   # one test
  23 make install                           # release build, then `ccebuild install --no-build cce-designer`
  24 ```
  25 
  26 Two binaries: `cce-designer` (the app) and `vk-smoke` (`src/vk_smoke.rs`) — a
  27 standalone renderer smoke test that opens its own window; run it inside a Wayland
  28 session with `cargo run -p cce-designer --bin vk-smoke`.
  29 
  30 The suite is pure CPU and needs no GPU, no OpenCL and no Wayland. (Until
  31 2026-09-24 it ran node kernels through the OpenCL runtime when one existed,
  32 with a CPU interpreter as the headless fallback, and `CCE_KERNEL_CPU=1` was
  33 the reliable way to run it — see "OpenCL is retired" below for why that is
  34 gone.)
  35 
  36 ### CLI modes
  37 
  38 - `cce-designer --thumbnail <project-dir-or-state.json> <out.png> [--size N] [--samples N] [--frame N]`
  39   — headless path-traced thumbnail (no Wayland, no window; `src/thumbnail.rs`).
  40   `cce-files` shells out to this for its preview cache. Without `--frame` there
  41   is no timeline and simnets render at their seed; with it the solve runs to
  42   that frame (start frame 1, the playbar's default), which is the only way to
  43   look at a simulation without a Wayland session.
  44 - `cce-designer --export <project> <out.stl|out.obj> [--frame N] [--node NAME] [--scale S]`
  45   — headless mesh export (`src/export_cli.rs`, formats in `src/export.rs`).
  46   The format comes from the extension, defaulting to binary STL. Without
  47   `--node` the whole visible scene is written; with it, that one node's output
  48   is written whether or not it is visible, which is normal for an Export node
  49   whose input something else already draws. Same frame contract as
  50   `--thumbnail`.
  51 - `cce-designer --detached-network` — a separate network-pane-only window. It syncs
  52   with the main window by autosaving/polling `default_project.json` mtime (see the
  53   main loop in `src/main.rs`) — there is no socket between the two.
  54 - `cce-designer --detached-params` / `--detached-spreadsheet` / `--detached-playbar`
  55   — the same idea for the other plates (`plate_corner::pane_detach_flag`), spawned by
  56   the plate corner menu's Detach. These windows are plain rectangles with standard CSD
  57   and no 3D canvas; `--detached-network` stays its own flag because that window is
  58   CIRCULAR, with a radial border resize no rectangular pane wants. All of them share
  59   the one `default_project.json` sync channel, and only the main window runs the MCP
  60   server. The parent keeps a stub for each pane it handed out — that stub's corner
  61   control is the only way to Reattach — and reaps its children with `try_wait` from
  62   the frame tick, so a window the user closes hands its pane back. NOT `kill(pid, 0)`:
  63   an unreaped exited child is a zombie, which that probe calls alive forever.
  64   Note that detaching REWRITES `default_project.json` in the source tree, since that
  65   file is the sync channel; it is versioned, so check `git status` after testing.
  66 
  67 ### MCP automation server
  68 
  69 The main window runs an embedded MCP server on `127.0.0.1:3001`
  70 (`CCE_DESIGNER_MCP_PORT` overrides so a second instance can run alongside;
  71 `src/api.rs`). This is the way to drive/inspect the running app: attach with
  72 `claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`, or
  73 speak JSON-RPC directly with curl (`initialize` / `tools/list` / `tools/call`).
  74 There is one tool per `McpAction` variant (tool name = the variant's serde
  75 tag, dispatched in `apply_mcp_call` in `src/window.rs`) plus `get_state`. The
  76 tool list lives in `mcp_tools()` in `src/api.rs`; the protocol layer is
  77 `cce_ui::mcp` (tools-only Streamable HTTP). Keep the enum, the tool list, and
  78 the schemas in sync — `test_mcp_tools_map_to_actions` enforces the mapping.
  79 (The former bespoke HTTP API on port 3000 was retired in favor of this;
  80 app-internal threads like the cce-files choosers now return results via
  81 `CustomEvent::RunAction` instead of POSTing to it.)
  82 
  83 ## Architecture
  84 
  85 The designer runs on cce-ui's standard `Application` trait / `engine::run` pattern
  86 (`src/application.rs` holds the impl; the engine owns the Wayland plumbing, calloop
  87 loop, and the `VkRenderer`). Because it draws a 3D scene and shapes its own text, it
  88 uses the engine's extended hooks — it is the reference consumer for them:
  89 `renderer_init` (create persistent meshes), `stage_renderer` (flush pending mesh
  90 updates, stage the raster scene / RT pane; returns true while the path tracer
  91 refines), `handle_resize`, and `standard_csd` / `cursor_icon` / `take_window_action`
  92 (the detached circular window's radial border resize + top-arc move). The 2D frame —
  93 geometry AND text — is the engine's single paint path: `display_list` returns
  94 `State::collect_display_list()` and `display_list_text` opts the text into the
  95 engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its own
  96 — the standalone `vk-smoke` bin is the one place that keeps its own, reached through
  97 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
  98 gone from cce-ui with the wgpu path).
  99 
 100 - `src/app.rs` (~8.9k lines) — the heart: `State` (the entire app model), `McpAction` /
 101   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
 102   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
 103   two halves of the old render loop. GPU mesh updates are staged CPU-side
 104   (`pending_*` fields, `spheres_dirty`) and flushed in `stage_frame` because only
 105   the engine hooks see the renderer.
 106 - `src/slots.rs` — the widget roster. Top-level widgets live in fixed slots on
 107   `WidgetSlots` addressed by `*_IDX` constants (`VIEWPORT_IDX`, `PARAM_IDX`,
 108   `NETWORK_PANEL_IDX`, … up to `WIDGET_COUNT`) rather than a dynamic tree; every slot
 109   is statically typed, and index-driven paths (draw order, focus cycling, broadcast
 110   loops) go through `get_dyn`/`get_dyn_mut`. The roster is declared once, as one line
 111   per slot in the `widget_roster!` macro invocation (`INDEX_CONST: field: WidgetType`),
 112   which generates the constants, `WIDGET_COUNT`, the struct fields and all four
 113   dispatch matches — adding a pane is that one line. The typed accessors that assert a
 114   slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
 115   `State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
 116   slot-only widgets, are also here.
 117 - `src/dialog.rs` — the Alt+D dialog: the `Dialog` widget (a third app-owned
 118   slot-only widget) plus the `State` half that fills it, routes its input and
 119   writes its settings back. See "The dialog (Alt+D)" below — three of its four
 120   hard parts are about paint order and occlusion, none of which is guessable
 121   from the widget.
 122 - `src/plate_corner.rs` — the plate corner control: a circular menu trigger on the
 123   top-right of each pane that draws its own plate (`PLATE_SLOTS` — network, params,
 124   spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
 125   Geometry is derived from the slot's live rect, so it holds across all three
 126   `rebuild_positions` branches; the circular network pane is special-cased onto its
 127   arc. The menu is a fourth `cce_ui::widget::context_menu` consumer alongside the node,
 128   viewport and network right-click menus, with the same `*_menu_actions` +
 129   `handle_*_menu_click` contract. Collapse shrinks a plate to its title stub via `apply_collapsed_panes`, a
 130   post-pass over `positions[..]` (one place, all three branches); the stub is exempt
 131   from the minimum-span guard or it would lose the control that expands it again.
 132 - `src/application.rs` — the `Application` impl: translates engine hooks into
 133   `WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
 134 - `src/window.rs` — `WindowEvent` plus the post-event side-effect pass
 135   (`process_window_event`: menu clicks, pane toggles) and HTTP-action application
 136   (`apply_custom_event`).
 137 - `src/render.rs` — `State::collect_display_list`: the frame's 2D content as one
 138   `cce_ui::scene::paint::DisplayList` (prims + `Prim::Text`), hand-maintained draw
 139   order over the widget slots, circular-pane clipping via `PaintItem::clip_circle`,
 140   network fade via text alpha. Rebuilt every drawn frame; the engine tessellates,
 141   shapes, and draws it.
 142 - `src/geometry.rs` — node-graph evaluation. Every evaluator threads an
 143   `EvalSim` (current frame + `SimCache` + feedback stack) alongside the error
 144   slot. The `simnet` node type iterates: the chain between its `input` and
 145   `output` children is one simulation STEP; step 1 eats the simnet's own
 146   `Input` (like a subnet), each later step eats the previous state, which the
 147   `input` node reads off the feedback stack instead of jumping to the outer
 148   graph. Solves run up to the playbar frame and cache per node id on `State::
 149   sim_cache` (playing forward = one step per frame); the cache key hashes the
 150   simnet subtree + seed, so edits restart the sim, and backward scrubs restart
 151   from the seed (steps are not invertible). The scene walk does NOT recurse
 152   into a simnet's children — that would draw one un-iterated pass of the chain
 153   on top of the solved result. Dived INTO a simnet, the output child's
 154   geometry flag draws the solved state, and every OTHER visible child draws
 155   itself as the current frame's step saw it, with the feedback stack holding
 156   the state that step consumed (`simnet_step_feedback`, read off the
 157   `SimSolve` the solve already keeps): `input` shows what the step reads, a
 158   chain node shows this frame's pass, and a node not wired into the chain at
 159   all simply draws. Until 2026-09-21 only the output flag drew, and a visible
 160   node inside a simnet was a node you could not see. At any
 161   displayed level `input`/`output` children draw their resolved geometry (top
 162   level of the walk only, so outer views don't draw subnet chains twice).
 163   Frame changes invalidate the scene only when the
 164   graph `contains_simnet`. `network_sphere_vertices_with_errors` walks the
 165   graph from output nodes; node failures are collected into one error slot
 166   (still named `ocl_error` from the days it held OpenCL's), not fatal.
 167 - `src/viewport_3d.rs` — app-owned `Viewport3D` widget (camera orbit/zoom, inertial
 168   scroll, `rt_mode` flag switching the pane to the `cce_ui::vk` compute path tracer).
 169 - `src/viewer_state.rs` — the **viewer-state framework**: interactive viewport
 170   tools, generalized out of the curve tool. A viewer state is a mode the
 171   viewport is in, bound to one node, in which the pointer edits that node
 172   instead of orbiting the camera. The framework owns everything that turned out
 173   to be the same for any such tool: projection of world positions to handles
 174   through `State::last_scene_mvp` + `last_scene_view_rect` (both LOGICAL px,
 175   the rect divided by scale where it is cached — the same path as the
 176   Point Numbers overlay), hit-testing against `cursor_x/y`, dragging by
 177   unprojecting the cursor at the grabbed handle's captured NDC depth, snapping,
 178   the HUD, per-gesture undo (`cce_ui::history::History` of handle snapshots on
 179   the tool, so it lives exactly as long as the state does), binding by node ID
 180   rather than slot so renames don't detach it and a vanished node drops the
 181   state lazily, and write-back through the SetParam resync sequence
 182   (`sync_nodes` + `rebuild_scene_geometry` + `sync_parameters_pane`).
 183   Input hooks live in `handle_event`: presses intercept in the MouseInput arm
 184   ahead of the viewport context menu (gated on `cursor_in_viewport() &&
 185   !in_network_pane`, so the network plate keeps its clicks where they overlap),
 186   motion at the top of CursorMoved, Escape ahead of connection-cancel.
 187 
 188   What differs per tool is the `HandleSource` trait: which node types it
 189   accepts, where the handles are, how to write them back, whether the pointer
 190   may add and remove them, and what to label them. `source_for` is the one map
 191   from node type to tool, so the node context menu's Edit Handles entry, the
 192   `edit_handles` command and any future entry point cannot disagree about what
 193   is editable — adding a source makes it appear in the menu without touching
 194   the menu.
 195 
 196   Two implementations ship, deliberately different in shape, because an
 197   abstraction with a single implementation has not been shown to be one:
 198   `src/curve_tool.rs` (an open-ended list of world positions in the `curve`
 199   node's Points parameter, extensible) and `src/soft_transform_tool.rs` (a
 200   FIXED pair where the second handle is `Centre + Translation` — a derived
 201   position that has to be converted both ways, which is exactly what the trait
 202   exists to contain). The soft transform's two handles read as a vector with a
 203   base and a tip, and dragging either end changes the offset between them; a
 204   rule like "keep the translation when the centre moves" would be right for the
 205   drag and would quietly discard half of every restored undo snapshot, since
 206   `write` is handed a full set of handles with no word about which moved.
 207 
 208   The HUD draws one line ABOVE the scale readout, sharing its left margin — not
 209   at the top, because the viewport is full-bleed and the pane plates float over
 210   its top edge, so a mode line there lands under the collapsed stubs. It exists
 211   because a viewer state changes what every click does and snapping silently
 212   changes what a drag does.
 213 - `src/project.rs` — save/load. A project is a **directory containing `state.json`**
 214   (`Project { name, root: FsNode, view_state }`); `default_project.json` in the crate
 215   root is special-cased as a single file and doubles as the detached-window sync channel.
 216 - `src/shortcut.rs` — `Shortcut::parse("Ctrl+Shift+g")` and chord → COMMAND ID
 217   matching (see the command registry above; a chord names a row in
 218   `src/command.rs`, not an `Action`). `Shortcut`'s equality is hand-written
 219   rather than derived, so it agrees with `matches` about case.
 220 
 221 The `zcce_inspector_v1` integration (window-position tracking + widget-state
 222 streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
 223 is the introspection surface.
 224 
 225 ### There are no meta nodes (retired 2026-09-23)
 226 
 227 Two different things were called `meta`, and both are gone. What replaced
 228 them is the one rule worth remembering: **a display setting belongs to the
 229 view, so it is a live field on `State`, persisted to `state.kdl`, and
 230 reached from the command palette.** Never a node.
 231 
 232 **The root `meta` node (nee Session)** was a permanent, undeletable root
 233 subnet holding four utility subnets — `main`, `view`, `guides`, `render` —
 234 whose params were every session-wide setting. It was the STORE OF RECORD:
 235 `ensure_menubar_subnets` rebuilt it from live state and
 236 `apply_settings_from_menubar_subnets` copied it back OVER live state after
 237 every parameter edit anywhere. Three things followed, all bad. A display
 238 preference was project data, carried in the file and reset by opening
 239 someone else's scene. Half of those settings were reachable only by finding
 240 the right node in the right subnet. And a command that flipped a live flag
 241 was undone by the next unrelated edit unless it also wrote the node — which
 242 is what `write_guides_toggle` / `write_render_toggle` existed for, and what
 243 made "Show Cube hides the cube until you touch any parameter" a real bug.
 244 
 245 **The per-node `meta` child** was a hidden child on every geometry node
 246 carrying four display switches (Point Markers, Point Numbers, Point Normals,
 247 Wireframe), so seeing the point numbering of what was on screen meant diving
 248 into each node and flipping its own switch, one node at a time. Wireframe
 249 was already duplicated by a global `toggle_wireframe`.
 250 
 251 Where it all went:
 252 
 253 - **Display settings** are live `State` fields, persisted by
 254   `DesignSettings` into `state.kdl` (`viewport` and the new `render` block),
 255   and edited as rows of the dialog's one list — `SETTINGS` in
 256   `src/dialog.rs`, whose rows are `Owner::Field` (a live field, with a `Ctl`
 257   saying what control draws it) or `Owner::ActiveCamera`; the toggles are
 258   registry commands whose palette rows carry a switch, read through
 259   `command_toggle_state`. The table plus the toggle commands are the app's
 260   whole display configuration, so a value left out of both is GONE, not
 261   merely hidden — `every_retired_subnet_setting_is_reachable` is the
 262   backstop, and `dialog_settings_rows_name_owners_that_exist` round-trips
 263   every `Field` row because a key no dispatch arm names draws, accepts an
 264   edit and does nothing.
 265 - **The three point overlays** are `toggle_point_markers` / `_numbers` /
 266   `_normals`, collected in `rebuild_scene_geometry` off the merged scene
 267   `Detail` (`render::scene_point_overlays`) rather than by a second walk that
 268   re-evaluated every flagged node. **Wireframe folded into the existing
 269   `toggle_wireframe`**, and the survivor draws the TOPOLOGICAL edge list
 270   (`render::scene_edge_verts`) the per-node flag used, not the triangle soup
 271   the global one did — shared edges once, quads as quads.
 272 - **Main's buttons** (New/Open/Save/Save As/Set As Default/Exit, Undo/Redo,
 273   the zoom family, Detach Circular Window) were already registry commands.
 274   Three settings that were toggles on those nodes and reachable NOWHERE else
 275   became commands: `toggle_ray_traced_preview`, `toggle_wire_single_color`,
 276   `toggle_render_points`.
 277 - **The recent-projects list** was the Main node's "Open" dropdown, which
 278   would have left `recent_files` written and read by nothing. It is rows at
 279   the head of the palette's Commands list (`RECENT_ROW_PREFIX`), under the
 280   open project's own path row; picking one opens it.
 281 - **Pane visibility** was the `view` subnet's five toggles riding `fs_root`
 282   into the file. It is genuinely project state, so it moved to
 283   `ProjectViewState::visible_panes` beside the collapse list and the
 284   splitters. `State::PANE_FLAGS` is the one table the save and the load share.
 285 - **The active camera** keeps the viewport menubar's own menu, whose entries
 286   are the camera NODES — not something a fixed table can hold.
 287 
 288 `Project::migrate_meta_settings_node` runs on every load: it takes the meta
 289 node (and the four subnets, which PRE-Session saves parked flat at the root —
 290 hence no early return on the container alone), reads its values onto the live
 291 state, and saves them to `state.kdl`. Per-node children go in
 292 `app::strip_meta_children`, called from `merge_template_defs` because that is
 293 the one function every deserialization runs. Their VALUES are dropped
 294 deliberately: four per-node booleans do not reduce to one global switch, and
 295 inferring one would turn a single node's preference into a setting over the
 296 whole scene.
 297 
 298 Gone with them: `session_node()`, `in_settings_dir()` (there is no settings
 299 directory, so Add Node offers every template everywhere), `write_meta_toggle`
 300 and its two wrappers, `refresh_main_node_live_toggles`,
 301 `update_recent_files_layout`, the `utility` / `session` / `meta` node types,
 302 the undeletable-node gate in `delete_node`, and `layout.rs`'s pinning (whose
 303 only pinned nodes were these).
 304 
 305 **"World Unit"** (mm / cm / m / in, `State::world_unit`) survives as a
 306 Settings row — what one world unit IS. Geometry never converts; the
 307 declaration feeds two things through the display metric (`cce_ui::units`):
 308 the viewport's bottom-left **scale readout** (`append_scale_readout`:
 309 `1:2.3`, `1 mm = 0.43 mm on screen`, marked when the metric is only assumed)
 310 and the viewport context menu's **View 1:1** (`view_one_to_one`), which moves
 311 the active camera along its eye ray so the pivot plane shows one world unit
 312 at its true length — the default camera by zoom, a camera node by rewriting
 313 its Position, as Frame All does. The projection is a perspective (vertical
 314 FOV 0.9 rad), so 1:1 holds on the pivot plane only; `view_scale_ratio` is the
 315 readout's number.
 316 
 317 ### App-written settings: `~/.config/cce/cce-designer/state.kdl`
 318 
 319 `default_project` in state.kdl points at the project the main window opens on
 320 startup (the `set_as_default` command; absent = the bundled
 321 `default_project.json`). It is a POINTER, never a rewrite of
 322 default_project.json — that file is versioned and is the detached-window sync
 323 channel. Detached windows ignore it: they must keep seeding from the sync
 324 channel.
 325 
 326 **A default that cannot be opened is not forgotten.** The launch falls back to
 327 the bundled project and says so on the status line, keeping the pointer. Until
 328 2026-09-23 a path that did not exist was DELETED from the settings, reasoning
 329 that a dead default should not fail on every launch — the trade is the wrong
 330 way round. Failing costs one line of stderr and a fallback that already works;
 331 forgetting costs a setting the user can only restore by reopening the project
 332 and pressing the button again. And a path is absent for reasons that pass — a
 333 cloud-synced folder the daemon has not mounted yet, an external drive, an
 334 autostart that beat the network — so the one launch that raced the filesystem
 335 took the setting with it, silently. (Found exactly that way: a default under
 336 `~/Dropbox` that stopped opening, with the key simply gone from state.kdl.)
 337 
 338 `DesignSettings` (viewport/graph display state the app rewrites itself:
 339 colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
 340 `config.kdl`, which is the user-authored toolkit-config override slot that
 341 cce-ui auto-merges (see `../cce-compositor/WORKSPACE.md`). Legacy `design.kdl` / `design.json`
 342 files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
 343 `scroll_friction`) is intentionally absent: it is config-owned
 344 (`input.inertial` in config.kdl) and must not be shadowed by app state.
 345 
 346 **The path honors `$XDG_CONFIG_HOME`**, resolved through
 347 `cce_ui::config::cce_config_dir()` like every other app in the workspace —
 348 this one hardcoded `$HOME/.config` until 2026-09-23 and was the only holdout.
 349 
 350 **And under `cfg(test)` it is a temp directory**, which is the part worth
 351 knowing. `State::new` loads the bundled project, whose meta subnets used to
 352 be copied over the live viewport flags after every parameter change (the
 353 meta node is retired, but the hazard was real and this redirect is what
 354 caught it); so any test that then reached `save_settings` —
 355 `run_command("toggle_network_plate")`, the dialog's toggle rows — wrote the
 356 BUNDLED project's show_grid / show_cube / show_origin over the user's real
 357 state.kdl. `cargo test` reset three of the user's own toggles on every run,
 358 and the run was green either way. `Project::load_recent_files` /
 359 `save_recent_files` are gated the same way, for a variant of the same reason:
 360 cce-ui derives that path from the EXE's basename, so test binaries had left
 361 seven real `~/.config/cce/cce_designer-<hash>/` directories behind.
 362 
 363 The redirect is in `DesignSettings::file_path` itself rather than in an
 364 environment variable the test module sets, because a variable leaves the
 365 guarantee resting on every future test remembering to set it BEFORE touching
 366 `State` — and the test that forgets destroys real settings, leaving nothing
 367 behind but toggles that came back wrong. `the_suite_does_not_write_the_users_own_settings`
 368 is the backstop: it spells the real path out itself (`file_path()` being the
 369 thing under test), runs the plate toggle, and asserts both that a settings
 370 file was actually written — or the check is vacuous — and that the real one
 371 did not move.
 372 
 373 **The suite's LATTICE is pinned for the same reason**, one layer up:
 374 `configured_grid_geometry` read `style.surface.graph.spacing_x` and friends
 375 straight out of `~/.config/cce/config.kdl`, and the grid tests press at pixel
 376 coordinates derived from `cell_center` and assert which node the press landed
 377 on — so the pitch on the machine decided whether they passed.
 378 `dragging_a_selected_node_carries_the_selection` really did fail at cce-ui's
 379 own defaults (187.5 x 112.5 puts its row 11 at 1237 px in a 900 px test
 380 window, so the press misses the node and the drag never arms); it passed only
 381 because the author's config.kdl set 140 x 70. A fresh clone, a second machine
 382 or CI would all have failed it, reading as a broken drag rather than a
 383 borrowed lattice.
 384 
 385 Under `cfg(test)` the four values are fixed at those 140 / 70 / 80 / 40 — the
 386 lattice the grid tests were written against, so pinning them changed no test's
 387 meaning. Deliberately NOT cce-ui's defaults: matching those would mean
 388 rewriting the cell arithmetic of a subtle drag test to fit a coarser grid,
 389 a real change to what it checks for the sake of a number that is arbitrary
 390 either way. What matters is that the number is the suite's own.
 391 `the_suite_runs_on_a_lattice_of_its_own` asserts the constants back — not a
 392 tautology but the thing that fails if the pin is ever unwired to the config
 393 again — and checks the live `State` alongside them, so the pin has to reach
 394 the app and not just the helper. Verified by running the suite under an EMPTY
 395 `$XDG_CONFIG_HOME`, under one setting 999 x 777 with 500 x 400 nodes, and
 396 under the real config: 305 passing, identically, all three.
 397 
 398 `graph_grid_snap` is not pinned — it is read inside cce-ui's Graph widget
 399 rather than through this crate, so there is nothing here to intercept; it is
 400 off both by cce-ui default and in practice. Config the suite still reads is
 401 cosmetic in the same way (colors, fonts, plate radii), and no test asserts on
 402 it; the empty-`$XDG_CONFIG_HOME` run is how to check that claim again.
 403 
 404 ### OpenCL is retired (2026-09-24)
 405 
 406 There is no OpenCL in this crate any more: no `opencl` node, no
 407 `kernel_cpu.rs`, no launcher, no `opencl3` dependency, no
 408 `CCE_KERNEL_CPU`. Phase 7 of `shapeshifter.md` is where the decision is
 409 argued; the short form is that the only scripting surface was a C subset
 410 carried by two backends that had to agree, every shipped kernel was serial
 411 (`if (id == 0)`), and the four templates that used them are native nodes
 412 now. Per-element scripting is the `wrangle` node (Rhai, CPU). GPU
 413 parallelism, when a solver needs it, comes back as WGSL compute through
 414 cce-ui's renderer — step 4 of the same phase — not as OpenCL.
 415 
 416 **An `opencl` node in an old save is not dropped.** `retired_opencl_node`
 417 passes its input through and reports `<name>: OpenCL nodes are retired;
 418 rewrite the kernel as a wrangle` through the error slot, so the status line
 419 says what happened and the fix is one rewrite. The type stays in
 420 `is_geometry_node_type` for exactly that arm.
 421 
 422 **What left with it, for the record.** Mesa's Rusticl ICD closed a file
 423 descriptor it did not own under `clGetPlatformIDs` (caught under `strace
 424 -k` on 2026-09-23), which had the suite failing one run in eight on
 425 whichever template file lost the race, and made `CCE_KERNEL_CPU=1` the only
 426 reliable way to run it. Probing less often did not help; forcing the CPU
 427 backend did not help until it also stopped loading the ICD; what worked was
 428 not loading it. The retirement is the final form of that fix. The
 429 diagnosis is in the git history of this section (commit `8fd0c29`) if the
 430 pattern ever recurs with another driver: a `read` returning EBADF on a file
 431 nothing is wrong with, in a process that has loaded a vendor ICD.
 432 
 433 ### Conditional parameter rows
 434 
 435 A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
 436 values deciding whether the params pane shows it: `Mode == Twist`,
 437 `Mode == Twist|Bend` for any-of, `Mode != Bleed` for unless, ` && ` between
 438 clauses, compared case-insensitively. Empty means always, which is what most
 439 parameters have. `param_visible` evaluates it and `param_display` filters on
 440 it.
 441 
 442 It exists because collapsing the Houdini operator set into fewer nodes traded
 443 node count for parameter count — `attribute` reached seventeen parameters, of
 444 which seven apply at once. Phrased the positive way round (unlike Houdini's
 445 `hideWhen`) because a template author is describing when a control APPLIES.
 446 
 447 Two rules worth knowing. A condition that does not parse, or names a parameter
 448 the node does not have, HIDES its row: a template bug should be visible, not
 449 silent — and `test_the_shipped_templates_only_name_parameters_they_have` walks
 450 every shipped template to catch exactly that. And hiding a row never touches
 451 its value: write-back resolves rows by display key rather than position, so a
 452 hidden parameter is simply not reported and comes back as it was.
 453 
 454 `merge_template_defs` carries `show_when` from the template like the rest of
 455 the UI metadata — the template owns when a control applies, the instance owns
 456 its value.
 457 
 458 ### Mesh export
 459 
 460 `src/export.rs` writes STL (binary and ASCII) and OBJ; `src/export_cli.rs` is
 461 the `--export` mode; the `export` NODE is a pass-through that writes when its
 462 Export button is pressed — never on evaluation, which happens on every redraw
 463 and every frame of a solve.
 464 
 465 The formats are not the same picture of a mesh. **OBJ keeps the topology**:
 466 points are written once, faces reference them, a quad stays a quad. **STL keeps
 467 only triangles** — it has no shared points, so everything fans and comes back
 468 welded-by-position at best. Neither carries attributes; the project file and
 469 the sim cache are what preserve a simulation's state.
 470 
 471 Coordinates are written as they are, scaled only by the node's Scale.
 472 The World Unit is a DECLARATION, not a conversion (see the Guides node), and
 473 export keeps that promise: geometry modelled at 20 units across writes as 20,
 474 and the slicer is told those are millimetres.
 475 
 476 Buttons dispatch through `execute_menu_action` by LABEL, which carries no node
 477 — `run_export` resolves the node from the current selection, which is sound
 478 because the pressed button can only be on the node the pane is showing.
 479 
 480 ### Mold tooling
 481 
 482 `src/mold.rs` is the first GEM operator, ported from the plugin's
 483 `gem_mold_shell`. Its four parameters are that node's — Maximum Thickness,
 484 Minimum Thickness, Remesh Division Size, Thickness Ramp — and the production
 485 notes from the original cast give the numbers that worked (0.75 / 0.6 / 0.9,
 486 linear), which are the template's defaults.
 487 
 488 **Thickness varies with curvature**, which is the whole point and the reason
 489 the `volume` node's uniform shell will not do. The plugin does it with an
 490 `im_ramp_scalar` named `curvature_to_thickness`; this does the same three
 491 steps — remesh to the division size, measure curvature per point, map it
 492 through a ramp into the thickness range.
 493 
 494 `curvature` is a signed DIMENSIONLESS measure in roughly -1..1: the mean of
 495 `dot(normalize(neighbour - p), n)`. Negative is convex, positive concave. Every
 496 term is a dot product of two unit vectors, so it does not move when the model
 497 is scaled or re-tessellated — which matters because thickness is chosen from
 498 it, and a measure that shifted with the remesh division size would give a shell
 499 whose thickness changed every time you re-tessellated. A true mean curvature in
 500 1/length would do exactly that.
 501 
 502 The curvature-to-thickness map is affine over a FIXED -1..1, not normalized
 503 over the range present in the model. Normalizing would make one part's
 504 thickness depend on how curved the rest of it is, so adding a sharp corner
 505 somewhere would thin the whole shell. Concave regions get the maximum: a mould
 506 is weakest where it cups inward, with least material behind it and the most
 507 leverage on it when the cast is pulled.
 508 
 509 The inner surface is a DISPLACEMENT along each point's normal, not a field
 510 offset — a signed distance field offsets by a constant and cannot vary per
 511 point. The cost is the usual one: where thickness exceeds the local radius of
 512 curvature the inner surface folds through itself. That is what the
 513 minimum/maximum range is for; it is a range because the geometry constrains it,
 514 not because one number was hard to pick.
 515 
 516 There is no ramp PARAMETER type in this app (cce-ui has the widget, nothing
 517 wires it as a node parameter), so the free-form float ramp is ported as the
 518 three-way choice the falloff parameters already use. Linear is the default
 519 because linear is what the cast that worked used.
 520 
 521 ### Parameter expressions (`ch()` references, Houdini's way)
 522 
 523 `src/expr.rs` is the expression language and `geometry.rs`'s `TreeScope`
 524 is what binds it to the node tree. **A parameter holds a value or an
 525 expression, and `ParamDef::expr` says which** — a flag, not a guess about
 526 the text, because a kernel's Code contains `chf(`, a node name is an
 527 identifier and `0.5` parses as an expression too. Houdini makes the same
 528 choice (a parm has a channel or it does not). An expression parameter is
 529 evaluated every time its node is: `resolve_param_refs(root, node, frame,
 530 error)` hands back a clone whose `expr` params are VALUES, at the top of
 531 `generate_single_node_geometry_with_errors`, the scene walk's `visit`, and
 532 the kernel path's parent read.
 533 
 534 **Paths are Houdini's.** Relative to the node holding the expression: a bare
 535 name is the node's OWN parameter, `..` its parent, `../sphere1/Radius` a
 536 sibling's, a leading `/` the root. `.x` / `.y` / `.z` reads a float3
 537 component. `ch()` / `chf()` read a number (a toggle 1 or 0, a choice its
 538 option INDEX — `chi("../Method")` is what lets a subnet's dropdown drive a
 539 child switch's Index), `chi()` truncates, `chb()` is 1 or 0, `chs()` the
 540 string (a choice's option text). The rest is `+ - * / % ^`, comparisons,
 541 `&& || !`, `$F` (the evaluation's frame), strings with `+`, and a fixed
 542 function set (`if(c, a, b)`, `clamp`, `fit`, `lerp`, `min`/`max`, `rand(seed)`,
 543 the usual math). No ternary — `:` separates a float3's components, which
 544 are three expressions each (`chf("../a/Size.x"):0:0`). An expression that
 545 reads an expression follows the chain; a circle is an error on the node,
 546 never a stack overflow. The written-back value is formatted for the
 547 TARGET row (`format_for_param`): a number into a toggle is `true`/`false`,
 548 into a choice its option name, into a spinbox an integer.
 549 
 550 **Until 2026-09-24 a bare `ch("Name")` meant the PARENT's parameter** (the
 551 whole value had to be one reference, nothing else). `Project::format` is
 552 the version that tells the two apart: 0 (absent) loads through
 553 `migrate_param_refs`, which turns each old reference into an expression
 554 with `../` added to a bare name, and saves as 1 — beside
 555 `sanitize_node_names` on every load path, and never twice, since a bare
 556 name in a format-1 file is the node's own parameter. Templates go through
 557 `infer_template_exprs` instead: a default that READS as a reference is one
 558 (`embryo.json` says `chf("../Radius")` now). The same inference applies to a
 559 value typed into a plain row or scripted through `set_param`: a reference
 560 becomes an expression; bare arithmetic does not, and is asked for through
 561 the row menu.
 562 
 563 **The params pane's right-click menu** (`param_row_at` → `open_param_context_menu`,
 564 a fifth `context_menu` consumer with the `*_menu_actions` +
 565 `handle_*_menu_click` contract, and `run_param_action` as the one entry the
 566 menu and the tests share) is Houdini's: **Copy Parameter**, **Paste
 567 Relative Reference** (`relative_ref_path`: `../sphere1`), **Paste Absolute
 568 Reference** (`/sphere1`), and **Edit Expression** / **Delete Expression** —
 569 the latter bakes the CURRENT value back as a value, as Delete Channels
 570 does. `copied_param` holds a node ID, not a path, so a rename between copy
 571 and paste still pastes the right path. The paste writes `chs()` when the
 572 target row holds text or a choice and `ch()` otherwise, by the TARGET,
 573 because that is what the value has to fit. Expression rows draw with a
 574 green tint (`render.rs`, PARAM_IDX arm) and as text in the pane
 575 (`param_display`), since a slider cannot hold one.
 576 
 577 **A rename carries every reference to the node** (`rename_node_in_tree`):
 578 expression paths that pass through it are rewritten textually
 579 (`expr::rewrite_paths`, so spacing survives), resolved from where each
 580 stands BEFORE the name changes since a path is names; sibling wires whose
 581 value is the old name follow, as the load-time sanitizer rewrites them;
 582 and the active camera. A same-named node elsewhere is not this one.
 583 
 584 ### Sibling-first inputs and the Switch node
 585 
 586 Two pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
 587 the way a Houdini HDA is — the Embryo is the first to be recomposed that
 588 way — both in `src/geometry.rs`:
 589 
 590 - **`find_input_node(root, target, name)` looks for a SIBLING first, then
 591   anywhere.** Every resolver used to search the whole tree from the top, so
 592   inside the second instance of a subnet a child wired to "input1" found the
 593   first instance's; the opencl and output resolvers had each grown a
 594   sibling-first lookup of their own to dodge exactly that. All 39 lookups go
 595   through it now.
 596 - **`switch`** passes one of `Input`, `Input 2` … `Input 4` by `Index`,
 597   clamped; an empty slot passes nothing. Only `Input` draws a wire, the
 598   limit every second operand has (Boolean's With, Copy's target).
 599 
 600 ### Sphere, Box, Plane and Extrude are native (2026-09-24)
 601 
 602 `src/shapes.rs` holds the four shapes that were kernel subnets — Phase 7
 603 step 3 of `shapeshifter.md`. Each was `input → opencl → output` with a
 604 kernel that ran under `if (id == 0)`: one work item doing loops, then a
 605 weld by position on the way back that threw away every shared point the
 606 loop had known. Native, each builds welded points and real primitives —
 607 a quad stays a quad — costs no JIT compile and needs no OpenCL at all.
 608 The parameter surfaces are the templates' own, so a saved instance keeps
 609 its values. The templates are plain native nodes now (`"type": "sphere"`
 610 and so on, no children); the Embryo is the one subnet template left, and
 611 its `sphere1` child resolves to the native Sphere with the template's
 612 whole surface under the Embryo's overrides.
 613 
 614 **The Sphere's Method** — `UV`, `Icosphere`, `Cube` — survives as it was:
 615 UV is Rows x Columns through `sphere_detail`; Icosphere splits each of the
 616 icosahedron's 20 faces into Frequency^2 triangles by integer barycentric
 617 weights; Cube lays a Resolution x Resolution grid on each face and pushes
 618 it out through the spherified-cube map, and builds QUADS where the kernel
 619 fanned them. Welded counts are `2 + (rows - 1) * cols`, `10 f^2 + 2` and
 620 `6 r^2 + 2`, which `sphere_method_builds_a_uv_ico_or_cube_sphere` asserts
 621 along with closedness. Welding is by a QUANTIZED position key (1e-5)
 622 rather than by trusting bit-identical arithmetic across faces: the kernel
 623 summed weights in one fixed expression so shared corners landed on the
 624 same bits, then welded at 1e-4 anyway; a quantized key is the same
 625 guarantee stated once. Colour is the kernel's — the SIGNED normal folded
 626 into 0..1, world-anchored — with Color on, `DEFAULT_COLOR` off.
 627 
 628 **A bare `sphere` node with no Center parameters is placed by index**
 629 (`index_center`), the way Line and Points still are: that is the tests'
 630 hand-built `ref_node("sphere", [Radius])`, and every node that came through
 631 a template or a load carries Center X/Y/Z and sits where they say.
 632 
 633 **Box** is eight corners and six quads about a float3 Center (new; the
 634 kernel hard-coded (0, 0.55, 0)), normals on the VERTICES like `box_detail`;
 635 Wireframe draws the twelve edges as bars and the corners as small cubes,
 636 as the kernel did. Its unused `Input` is gone. **Plane** is the kernel's
 637 sheet with its colour gradient; Grid is the same sheet with a float3 Center
 638 and no gradient, two nodes for history's sake.
 639 
 640 **Extrude extrudes AS A WHOLE**, which is the one semantic change: every
 641 point moves along its point normal, the input's primitives become the
 642 top, one quad wall rises from each BOUNDARY edge, and Keep Base keeps the
 643 originals wound the other way — a sheet becomes a closed slab, a closed
 644 surface a two-skinned shell. The kernel extruded every triangle on its
 645 own and welded the prisms back together, which put a wall along every
 646 interior edge. Point attributes and groups ride to the top copies; the
 647 kernel's 15% darker walls were a per-corner colour a soup could hold and
 648 shared points cannot, and are gone.
 649 
 650 **Saved kernel subnets migrate on load.** `nativize_kernel_subnets` in
 651 `merge_template_defs` turns a `node` whose children include an `opencl`
 652 child and whose base name is one of the four into the native node: id,
 653 name, position, flag and values stay, the children go, and a parameter the
 654 native template lacks goes with them. Only when the `opencl` child is
 655 actually there, so a subnet someone built by hand and called "sphere2"
 656 keeps what is inside it. The bundled `default_project.json` and
 657 `project.json` were converted in place, and
 658 `test_loader_merges_new_template_params` is the migration's test. The
 659 `opencl` node and both kernel backends were retired the same day (above).
 660 
 661 ### The Embryo node is a template of nodes
 662 
 663 `nodes/embryo.json` is hou-control's `developer_embryo`, the Developer
 664 family's first Pre-Simulation operator — "the seed geometry a simulation
 665 starts from" — as a SUBNET of ten ordinary nodes wired the way the HDA's
 666 network is, its controls reaching the children through parameter references
 667 (above). Dive in and the pipeline is there to read, break and reuse: `input1`
 668 and a `sphere1` (Radius `chf("../Radius")`, Rows and Columns
 669 `chi("../Base Resolution")`) behind `source1`, a `switch` whose Index is
 670 `chi("../Source")`; `scatter1` in Surface mode reading the Scatter folder's
 671 controls, `hull1` behind it, and `method1`, a switch on `chi("../Method")`
 672 between the source and the hull; then `relax1` in Repel mode, `subdivide1`,
 673 `normal1`, `output1`. The defaults are the HDA's, and
 674 `embryo_template_builds_a_sphere_a_hull_or_the_input` drives the template
 675 end to end.
 676 
 677 It was a native node for one day (2026-09-21, `src/embryo.rs`, a pipeline in
 678 Rust), which is the wrong shape for this app: CLAUDE.md refuses `gem_graph`
 679 for the same reason, and a node you cannot dive into cannot be learned from.
 680 Recomposing it needed four reusable pieces, all of which outlive it:
 681 parameter references (now expressions, their own section above) and the
 682 `switch` node, the
 683 `hull` node (`src/hull.rs` — the incremental convex hull; points that span
 684 no volume pass through), and two modes on existing nodes (`src/scatter.rs`):
 685 **Scatter's Surface mode** (points ON the surface by area, seeded, optionally
 686 pushed apart across it with a radius derived from the area per point — the
 687 Scatter SOP with Relax Points) beside its original Volume mode, and
 688 **Relax's Repel mode** (spheres of Radius pushed apart, sliding in the
 689 tangent plane unless In 3D Space; zero iterations is off) beside its
 690 original Springs mode. A native `embryo` in an older save is recomposed on
 691 load (`recompose_native_embryo` in `merge_template_defs`): id, name,
 692 position, flag and values carry over, the template's children arrive fresh.
 693 
 694 Two deliberate differences from the HDA. **Subdivide does not smooth**: it
 695 is this app's `remesh::subdivide` (four triangles per triangle, points
 696 unmoved), where the HDA runs Catmull-Clark — same parameter, one operation
 697 rather than two under one name. **The second input is the first**: the HDA
 698 read its Source from input 2, and this app's nodes name one Input.
 699 
 700 **Exactly one child of the template draws, `normal1`**, the last real
 701 node. A subnet viewed from
 702 OUTSIDE shows its internals by their own flags (output children draw only
 703 at the displayed level), so with every chain node visible the hull drew
 704 five times over, each draw re-evaluating the pipeline: 2.4 s per edit on a
 705 release build, 0.1 s with one. Template child specs therefore carry
 706 `geometry_visible` through `load_fs_tree` (absent means on, as before).
 707 
 708 Nesting a subnet template inside a template (the Embryo's `sphere1` is the
 709 Sphere template) is what made `load_fs_tree`'s child resolution recursive:
 710 a base that is itself a subnet brings raw children of its own, and those
 711 resolve the same way, or the nested sphere's kernel node arrived with only
 712 the params its override named. Depth-bounded, so a template that contained
 713 itself would fail rather than recurse forever.
 714 
 715 ### The wrangle node
 716 
 717 `src/wrangle.rs` is a script run once per element, on Rhai — Phase 7 step 1
 718 of `shapeshifter.md`, and the app's scripting surface for per-element work
 719 where the retired `opencl` node used to be the only one. The engine is a dependency;
 720 what the module owns is the BINDING to the `Detail`, and it is VEX-shaped on
 721 purpose so `@P.y += sin(@P.x) * 0.1;` reads as it does there.
 722 
 723 `@name` is sugar. Rhai has no `@` token, so `desugar` rewrites `@name` into
 724 an index on an element marker (`__at["name"]`) outside strings and
 725 comments, and everything after it — `.x`, `+=`, `[0]` — is Rhai's own syntax
 726 on the value that came back. The indexers reach the geometry through a
 727 shared context; the marker is a VARIABLE in the scope, not a constant,
 728 because Rhai refuses to assign through an indexer on a constant and `@P = …`
 729 is exactly that (the first cut used `push_constant` and every write failed
 730 with "Cannot assign to indexer of constant"). Naming an attribute creates it,
 731 typed by the first value written — a float, an int (a bool is an int), a
 732 `vec3`, an array of two or four — and a write to an existing attribute
 733 converts to ITS type, so `@mass = 2` into a float attribute is `2.0`. A
 734 float2 reads back as a `vec3` with z = 0, a float4 as an array. `@P`, `@Cd`,
 735 `@N` (computed on read when absent), `@id`, `@ptnum` / `@primnum`, `@numpt` /
 736 `@numprim` and `@Frame` are intrinsics; on the Primitives class `@P` is the
 737 centroid and read-only, and on Detail `@name` is a detail attribute.
 738 
 739 **`ch("path")` is resolved BEFORE the run, not called during it.**
 740 `channel_refs` scans the script for the paths it names as string literals,
 741 and the evaluator in `geometry.rs` resolves each through the expression
 742 `TreeScope` — the one scope, so a parameter that is itself an expression is
 743 evaluated first and the script sees its value; that is the seam Phase 7's
 744 step 2 names, and neither language knows the other exists. Two things follow:
 745 `ch` costs a map lookup per element rather than a tree walk, and a path built
 746 at runtime is an error that says why. `chs` reads text, `chv` a float3, `chi`
 747 truncates.
 748 
 749 `neighbours(pt)`, `prims(pt)`, `points(prim)` read the derived topology and
 750 `nearest(pos, r)` the point grid — built once at the first call from the
 751 positions as they then stand, and keyed by radius. `point(name, i)` /
 752 `setpoint`, `prim` / `setprim`, `detail` / `setdetail`, `ingroup` /
 753 `setgroup` reach elements other than the current one. `addpoint`, `addprim`
 754 and `removepoint` are DEFERRED and applied after the run, so a script
 755 iterating points sees a stable count; `addpoint` returns the index the point
 756 will have, which is what makes `addprim([a, b, c])` in Detail class a way to
 757 build geometry from no input at all — a wrangle with nothing wired still runs.
 758 
 759 Ints and floats mix (`@P.y * 2` works), which Rhai does not do on its own;
 760 the mixed arithmetic and comparison operators are registered by hand, as are
 761 `vec3`'s. Two budgets: `OPS_PER_ELEMENT` operations per element, which is
 762 the retired `kernel_cpu`'s step budget as a setting rather than a hand-rolled counter,
 763 and `RUN_BUDGET` seconds of wall clock for the whole run, checked in
 764 `on_progress` every few thousand operations. Any failure — syntax, a runtime
 765 error on an element, a budget — fails the WHOLE run, named by node and
 766 element (`wrangle1: point 4: …`), and the input passes through untouched: a
 767 half-wrangled geometry is not a result. Compiled scripts cache by desugared
 768 source in a thread-local, as the retired launcher cached kernels.
 769 
 770 CPU only, deliberately: an interpreter is an order of magnitude or more
 771 below native Rust, which is fine for tens of thousands of elements per edit
 772 and wrong for a solver at a million per frame. That is Phase 7's step 4
 773 (WGSL compute through the renderer), not a reason to grow this.
 774 
 775 **The Code row applies on ctrl+enter, Escape or leaving the row — never per
 776 keystroke.** cce-ui's `ParametersBg` code editor (line numbers, selection,
 777 clipboard, tab indenting, auto-indent, undo) keeps edits in its buffer
 778 until one of those, because this node evaluates on every value change and
 779 a half-typed line would fail on every keystroke — the border is amber
 780 while edits are pending. **A script error's line is flagged in the row**:
 781 `code_error_line_for_pane` in `render.rs` reads the `(line N` out of the
 782 evaluation error when the node it names is the one the pane shows, and
 783 hands it to `set_code_error_line`; Rhai's line numbers survive `desugar`
 784 because the `@` rewrite never adds or removes a line. Cleared on the next
 785 evaluation that says nothing about that node.
 786 
 787 ### GPU compute: the springs solve is the first operator (Phase 7 step 4)
 788 
 789 `src/gpu.rs` keeps one `cce_ui::vk::ComputeDevice` per thread, opened on
 790 first use and kept, so the pipeline cache and the buffers survive from one
 791 edit to the next; a device costs tens of milliseconds to open and a kernel
 792 a few to compile, and an operator that paid both per evaluation would lose
 793 to the CPU every time. `CCE_COMPUTE` decides: unset or `auto` takes the GPU
 794 when there is one and the operator judges the input big enough; `cpu`
 795 never opens a device; `gpu` insists, and an operator that cannot get one
 796 says so through the node-error slot rather than silently taking the CPU
 797 path. **Under `cfg(test)` auto means CPU**, so the suite is the same on
 798 every machine and the GPU is exercised only by the tests that ask for it
 799 by name — the cross-checks. The suite never SETS the variable: libtest
 800 runs tests in parallel and one that did would race every other test
 801 reading it (`gpu::parse` is the pure function the choice test covers).
 802 
 803 `src/springs.rs` is the pattern every later operator follows: one
 804 algorithm, one data layout, two backends held to each other by a
 805 cross-check (`springs_gpu_matches_cpu`, agreement to 1e-4 over 1.5k
 806 points; it skips with a note where there is no Vulkan). The layout is the
 807 GPU's — positions as a flat `xyz` array because a `vec3<f32>` in a WGSL
 808 storage array pads to 16 bytes, the rest topology as CSR with the rest
 809 length on each incident entry, pins as one `u32` per point — and the CPU
 810 walks the same arrays in the same order. `solve` chooses the backend; a
 811 GPU failure in auto mode falls back to the CPU with one stderr note.
 812 
 813 **Relax's Springs mode is a JACOBI solve now.** Until 2026-09-24 it was
 814 Gauss–Seidel over the edge list in sequence, every correction visible to
 815 the next edge, which no per-point kernel can reproduce; rather than let a
 816 GPU Jacobi and a CPU Gauss–Seidel drift apart, both run Jacobi: each point
 817 gathers the corrections of its incident edges from the pass's starting
 818 positions — half the error toward a free neighbour, all of it toward a
 819 pinned one — averages them, and moves once. It converges roughly half as
 820 fast per iteration, which Iterations already controls; the pinned-pull
 821 test that defines the node's behaviour passes unchanged.
 822 
 823 **The whole solve is ONE submission** (`run_passes_over` with a ping-pong
 824 pair): the topology goes up once, the passes are chained by memory
 825 barriers with the positions alternating between two device buffers, and
 826 the result comes back once. The first cut submitted a pass at a time and
 827 LOST to the CPU at every size measured, 134k points included — a
 828 submission's round trip is about half a millisecond on an integrated GPU
 829 whatever the dispatch inside it, and sixteen of them buried a solve that
 830 takes microseconds. `springs_timing` (ignored; run in release with
 831 `--ignored --nocapture`) is the measurement, on an Intel Iris Xe, sixteen
 832 passes: 1.5k points cpu 0.25 ms / gpu 1.5 ms; 15k cpu 2.6 ms / gpu 3.7 ms;
 833 135k cpu 25 ms / gpu 14 ms, plus ~15 ms of pipeline compile on a device's
 834 first run. `GPU_MIN_POINTS` (32k) is the auto threshold that follows: the
 835 GPU is a win for large meshes and a loss for the ones most projects have,
 836 which is the honest state of step 4 and the reason auto does not simply
 837 mean GPU.
 838 
 839 **Collision is the second operator (`src/collide.rs`), and the one the
 840 GPU is made for.** The node's test has always been a brute-force loop —
 841 every query against every collider triangle, the Voronoi-region distance
 842 for Proximity and a Möller–Trumbore parity cast for Inside — so the work
 843 is queries x triangles, per-point, one dispatch, no passes to chain. The
 844 resolver now runs the test as ONE batch over every element the type asks
 845 about (points, or primitive centroids), where it used to hand
 846 `select_elements` a closure that asked one point at a time; that batch is
 847 what can go to the GPU whole. Same algorithm step for step on both sides,
 848 held by `collision_gpu_matches_cpu` (zero disagreements over 6k queries x
 849 1.7k triangles in both modes; a knife-edge query at the threshold may
 850 round either way and is tolerated only there). `collision_timing` in
 851 release, Proximity: 3.6M pairs cpu 20 ms / gpu 2.2 ms; 15M pairs cpu 76
 852 ms / gpu 6.6 ms; 242M pairs cpu 1150 ms / gpu 52 ms. `GPU_MIN_WORK`
 853 (250k pairs) is the auto threshold.
 854 
 855 **Two per-point operators are deliberately NOT on the GPU, and the
 856 measurements above say why.** Neighbour's Diffuse and Concentrate are a
 857 single gather per evaluation — one pass, then the rest of the graph runs
 858 on the CPU before the next frame's pass — so there is nothing to chain
 859 into one submission, and a single pass costs ~0.5 ms of round trip against
 860 a CPU gather that takes less than that on any mesh a project has. Relax's
 861 Repel rebuilds a spatial grid every pass, which is the part that does not
 862 fit a chained submission; a GPU-side grid is a project of its own, and a
 863 brute-force O(n^2) pass that the CPU twin would then have to match is a
 864 regression for every CPU user. Both stay native until a workload asks.
 865 
 866 ### The volume representation
 867 
 868 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
 869 dims, data }` — with two nodes on it: `volume` (offset and shell) and
 870 `boolean` (union, intersect, subtract). It exists because shelling, offsetting
 871 and booleans are not mesh operations. Doing them on triangles means answering
 872 "which side of this whole surface is that point on" per triangle pair; doing
 873 them on a field means `min`, `max` and a sign flip, and the mesh comes back out
 874 by extraction.
 875 
 876 **Signing the field is the whole difficulty**, and it is done in two parts
 877 because neither part is right everywhere:
 878 
 879 - **Far from the surface**, a flood fill from the grid boundary — which is
 880   outside by construction — marks everything it can reach. Whatever it cannot
 881   reach without crossing the surface is enclosed, however convoluted the
 882   cavity. The flood may only step between samples that are *both* further than
 883   `voxel * 1.01` from any surface, because two samples one voxel apart cannot
 884   both be more than a voxel from a surface lying between them. A looser band
 885   (0.75 voxel was the first try) lets the flood walk straight through a thin
 886   wall and the solid comes back hollow.
 887 - **Inside that band**, the flood has nothing to say, so the nearest face's
 888   normal decides. That test trusts the winding, so the winding is *measured*
 889   first — the signed volume by the divergence theorem, positive when faces look
 890   outward — and the test flips if the mesh is inside out. An imported mesh is
 891   not obliged to agree with this app's convention, and one that disagrees used
 892   to come back with its band signs alternating against the flood's.
 893 
 894 Ray parity was the first approach and is wrong: a ray through a shared edge
 895 crosses two triangles at one point and counts two, so the parity inverts for
 896 every sample behind it. It failed on 79 of 15625 samples in contiguous runs,
 897 which is what a parity bug looks like.
 898 
 899 Extraction is naive **surface nets** (`to_mesh`): one vertex per cell that has
 900 a sign change, placed at the average of its edge crossings, and one quad per
 901 crossed grid edge joining the four cells around it. Chosen over marching cubes
 902 because it produces quads on a quad grid and far fewer degenerate slivers.
 903 
 904 One vertex per cell is also its limit. Where a feature is thinner than a voxel
 905 — the knife-edge rim of a subtraction — two sheets of surface share one cell's
 906 vertex and pinch, leaving edges with four faces. The result is still
 907 watertight; it is not manifold. Hence two predicates on `Detail`, and the
 908 difference matters: [`is_closed`](src/detail.rs) asks that every directed edge
 909 have exactly one opposite (no boundary, consistently wound — what having an
 910 inside requires, and what `Volume::build` guards its input with), while
 911 `is_manifold` asks for exactly two faces per edge (what remeshing requires,
 912 since an edge with four faces has no single pair to flip between).
 913 
 914 `Volume::build` takes an explicit `reach`: distances are clamped there, so the
 915 field is exact near the surface and flat far from it. A boolean builds both
 916 operands on ONE grid so the two fields line up sample for sample.
 917 
 918 ### The 2D page context
 919 
 920 `src/page.rs` is a second context, not a second kind of geometry node. Its
 921 currency is a `Page` — a printed sheet: inches, a DPI, and straight-alpha RGBA
 922 pixels — its origin is the top-left corner with y running DOWN, and nothing in
 923 it has a point id, an attribute or a normal. Four nodes compose one: `page`
 924 (the sheet: preset or custom size, orientation, resolution, colour),
 925 `page_grid`, `page_border` and `page_text`.
 926 
 927 The two contexts do not mix, and `is_page_node` is the one place that says so.
 928 A page node contributes nothing to the viewport's geometry and a geometry node
 929 cannot feed a page: page chains resolve through `resolve_page`, never through
 930 `generate_single_node_geometry_with_errors`. `export` is the only node in
 931 both — it passes either through, and what reaches it decides the format, so a
 932 page writes a PNG and geometry writes the mesh format its Format parameter
 933 names. There is no PNG option on that parameter, because offering one for a
 934 mesh would be a lie.
 935 
 936 **Resolution is a property of the page, not of the export.** The raster is
 937 size × DPI, and `write_png` puts that in the pHYs chunk, so a printer lays the
 938 file out at the size it was composed at instead of guessing 96. pHYs is pixels
 939 per metre — the only unit PNG offers — so the DPI round-trips through a
 940 conversion and comes back a hair off (300 stores as 11811 px/m, reads as
 941 299.9994). Inches rather than millimetres because paper is specified in inches
 942 by the family this came from; the geometry graph's World Unit declaration does
 943 not reach here.
 944 
 945 Rect coverage is exact area, not a test of the pixel centre. A printed grid is
 946 mostly hairlines, and a binary fill snaps every rule to whole pixels, so a
 947 ruled sheet comes out with lines alternating between one and two pixels wide
 948 down its length — which reads as a wobble in the paper rather than as
 949 aliasing. Grid rules are centred ON their coordinate so a second grid at twice
 950 the cell size lands exactly on the first's, which is the only reason to draw
 951 two. Text shapes and rasterizes through cosmic-text, the toolkit's own font
 952 stack, with system fonts loaded because a page names its font by family.
 953 
 954 **The preview pane** (`PAGE_IDX`, an `ImageView`) takes the viewport's rect
 955 when the displayed level holds a page, and the viewport stands down — the same
 956 rule the viewport already follows about showing its editor's level. Three
 957 things were needed to make a new pane actually appear, and missing any one of
 958 them looks identical to the others:
 959 
 960 - A `PAGE_IDX` arm in `paint_widget`. The fall-through branch serves LEGACY
 961   widgets — it emits a plate and the widget's legacy views — so a modern-paint
 962   widget whose whole look lives in `Paint::paint` lands there and draws
 963   nothing. The pane was visible, correctly placed and blank.
 964 - The viewport's key in the `draw_order` sort. The viewport is full-bleed and
 965   the other panes float OVER it, so a pane taking its rect must take its depth;
 966   drawn last, it covered the collapsed stubs and their labels ghosted through
 967   from the later text pass.
 968 - An entry in `test_widget_roster_indices_are_dense`, which is hand-listed and
 969   fails loudly — the one of the three that tells you itself.
 970 
 971 The GPU image is owned by `State::page_image` and freed when replaced;
 972 `ImageView` only borrows the id. **A replacement renderer invalidates that id.** There is no reconnect
 973 callback: the runner calls `renderer_init` once per renderer, so the first call
 974 is this process's own and every later one is a replacement — remembering is the
 975 only way to tell them apart (`State::seen_renderer`, via
 976 `renderer_handed_over`, which is split out of the callback so it can be tested
 977 without a live `VkRenderer`). Images uploaded outside that callback are not
 978 replayed, so a cached id names nothing and its draws are skipped in SILENCE:
 979 the page pane just goes blank. The id is dropped and `page_dirty` asks the next
 980 tick to recompose and re-upload — the raster is cheap to rebuild from the node
 981 graph, and no id can be carried across renderers. Found by cce-1f's audit of
 982 clients caching vk image ids.
 983 
 984 **To test it**, put `CCE_UI_FAULT_RECONNECT=<seconds>` on the binary's
 985 environment: the runner drops the session that many seconds in, exactly as a
 986 transport error would, and the app reconnects with a new renderer. Run the OLD
 987 binary through the same fault first — a fix that passes a test which never
 988 reproduced the bug is worth nothing. Judge by the picture: the designer inits no
 989 logger, so the runner's WARN never appears even when it fired. Verified this way
 990 on 2026-09-19 — with the fix disabled the sheet vanishes at the fault, with it
 991 the sheet survives.
 992 
 993 `gem_graph`, the source family's
 994 everything-at-once node, is deliberately not ported: it is these four chained,
 995 and that collapse is the whole premise of "fifty operators, ten nodes".
 996 
 997 ### The network plate is optional
 998 
 999 The network pane can drop its PLATE — the filled, frosted surface its graph
1000 sits on — so the nodes and wires overlay the 3D scene directly. The viewport is
1001 full-bleed (`CANVAS_IDX` covers the window and the other panes float over it),
1002 so removing the plate is all it takes: what is behind the pane is the scene.
1003 
1004 The pane itself is untouched. It keeps its rect, its focus domain, its corner
1005 menus, its clip and its keyboard navigation; only two `append_widget_plate_radii`
1006 calls are skipped — `CONTENT_IDX`'s (the graph's own plate) and
1007 `NETWORK_PANEL_IDX`'s (the panel behind it). Skipping one and not the other
1008 leaves a surface, so both are gated on the same flag. Node bodies keep their
1009 blur-behind fill, which is what makes the result legible: they frost the scene
1010 behind each node while the gaps stay clear.
1011 
1012 **With the plate off the pane spans the whole window.** The dock rect is
1013 overridden at its source in `rebuild_positions` — one `let (px, py, pw, ph)`,
1014 so content, panel and breadcrumb all follow — because there is no surface left
1015 to bound the graph, and one confined to a rectangle you cannot see is worse
1016 than one that spans what it is drawn over.
1017 
1018 That makes the pane's RECT useless as a hit test, and three things route off it:
1019 
1020 - **Clicks** ask `in_network_pane`, which in overlay mode narrows to "a node is
1021   under the cursor, and no floating pane covers it" (`overlay_claims`). The
1022   same refinement goes into the press cascade's `hits_widget` closure, where
1023   the circular pane already refines its own hit test. Without it the graph
1024   claims every press in the window, including ones landing on a node drawn
1025   UNDER the params pane.
1026 - **Pan gestures** ask `in_network_area` instead — the plain rect. Middle-drag
1027   and space+left mean nothing to the scene, so the network keeps them across
1028   its whole span; a graph you could not pan by dragging because its own surface
1029   stopped being drawn would be a strange thing to ship.
1030 - **`cursor_in_viewport`** becomes the complement: the body, minus what the
1031   network holds, minus the floating panes.
1032 
1033 What changes for the user: a plain click on empty space is no longer the
1034 network's — it ORBITS THE CAMERA instead (see below), which is what makes the
1035 overlay feel like a scene with a graph on it rather than a graph with a
1036 picture behind it. Deselecting is on Escape.
1037 
1038 ### Deselecting has to stick
1039 
1040 The selection IS whatever sits in the grid cursor's cell — that is what
1041 `sync_cursor_and_selection` means — and that sync runs on nearly every frame
1042 where anything changed. So `set_selected_node(None)` alone does not deselect:
1043 it is put straight back on the next frame, and the pane never clears.
1044 
1045 `State::deselect_node` therefore remembers the CELL it happened in
1046 (`deselected_cell`), and the sync leaves that one cell alone. A cell rather
1047 than a flag, so the suppression is exactly as narrow as it should be: step the
1048 cursor anywhere else and selection resumes by itself, and stepping back onto
1049 the node selects it again. A selection arriving from anywhere else — a click, a
1050 load, the params pane — spends the memory at the top of the same sync, or
1051 clicking the very node you just deselected would clear itself again.
1052 
1053 Escape runs it LAST, after the context menus, the viewer state and
1054 connection-cancel: Escape is this app's one "get me out" key, and all of those
1055 are more immediate than a selection. There is also a `deselect` command, shipped
1056 UNBOUND so it is findable in the palette — deliberately not Ctrl+D, which the
1057 plugin uses for deselect-all but which this app already gives to Circular Pane.
1058 
1059 `ViewportSettings::network_plate` persists it, beside the viewport toggles
1060 rather than in the project's pane-state list: a pane's VISIBILITY belongs to
1061 the project, but whether its surface is drawn is how you like to work, and it
1062 should outlive any one file. It is reachable three ways that cannot disagree,
1063 because all three run one `Action::ToggleNetworkPlate` — the View settings
1064 node's Network > Plate row, the network pane's View menu ("Network Plate"), and
1065 the `toggle_network_plate` command. The action marks `settings_changed` and
1066 lets `execute_action` save once at its end, like every other viewport toggle,
1067 rather than writing the file itself.
1068 
1069 ### The network editor's right-click menu
1070 
1071 A right press on EMPTY graph space opens the network's own context menu; a press
1072 ON a node still opens that node's menu, which is the more specific thing under
1073 the pointer. Until 2026-09-22 the empty-space press opened the **add-node
1074 palette** outright, which left the network the one pane whose right-click was
1075 not a context menu, and left every other graph-wide command reachable only by
1076 chord or through the palette. **Add Node is the first row** instead, and picking
1077 it opens the same palette.
1078 
1079 Rows are `NETWORK_MENU_COMMANDS` — a list of COMMAND IDS, `None` for a
1080 separator — resolved through `command::by_id`, so a label is the registry's
1081 label and `NetworkMenuAction::Command(id)` dispatches through `run_command`.
1082 The menu therefore cannot name work the palette spells differently, and a row is
1083 exactly as scriptable as the command behind it.
1084 `network_menu_rows_name_commands_that_exist` is the backstop, since a row whose
1085 id no longer resolves is simply skipped. A toggle command carries the viewport
1086 menu's `●`/`○` mark, read through `command_toggle_state` — the one table the
1087 dialog's switches read too.
1088 
1089 `add_node` is a registry row of its own now (`Run::Menu("Add Node")`), where the
1090 palette used to be reachable only from Tab's inline handler. It ships UNBOUND,
1091 like `deselect`: Tab already opens it from the event loop, and a default chord
1092 here would duplicate a key the loop claims.
1093 
1094 With the plate OFF the press never gets here — `in_network_pane` narrows to the
1095 nodes in overlay mode, so empty space is the scene's and opens the VIEWPORT
1096 menu. That is the overlay's whole rule, and it predates this menu.
1097 
1098 The press moves the grid cursor to the clicked cell BEFORE the menu goes up,
1099 because that cell is where Add Node will place what it adds — the cursor is the
1100 only thing carrying the pointed-at cell across to the palette.
1101 
1102 ### Keyboard graph navigation
1103 
1104 The network pane's keyboard scheme is the plugin's, ported: **hjkl rather than
1105 arrows** — the arrows are the playbar transport in every pane and context — bare
1106 to move the grid cursor, `shift` to extend it into a region, `alt` to move the
1107 selected nodes, `ctrl` to pan the view, plus `f` to frame the cursor and
1108 `shift+f` to frame everything. All eighteen are registry commands in
1109 `Context::Network`, so they are rebindable through `input.kdl` and listed in
1110 the palette.
1111 
1112 **The grid cursor IS the selection.** `sync_cursor_and_selection` selects
1113 whatever node sits in the cursor's cell, so navigating selects, and stepping off
1114 a node deselects. Every family is gated on the network pane having focus — one
1115 gate, in the four `network_*` methods. The bare family used to be the one that
1116 was NOT gated: plain h/j/k/l moved the cursor from any pane, so it drifted
1117 invisibly while you were looking at the viewport (the selection did not follow,
1118 because `sync_cursor_and_selection` has its own pane check) and was somewhere
1119 unexpected when you came back.
1120 
1121 `alt` moves the SELECTION and the cursor, so a run of `alt+h` drags what is
1122 selected across the sheet rather than leaving it behind on the first press. `ctrl` pans by one CELL
1123 rather than a fixed pixel count, so a pan step means the same thing at every
1124 zoom. Frame Cursor CENTRES the cursor cell; its first version called
1125 `keep_cursor_in_view`, which pans only when the cursor has gone off an edge, so
1126 the command did nothing at all in the common case of a cursor that is visible
1127 but off in a corner — which is exactly when it gets pressed.
1128 
1129 `shift+hjkl` — the plugin's extend-the-selection family — grows the cursor's
1130 region (below) by one cell. It was absent while the graph's single
1131 `selected_node` was the whole selection, when four rows would have done what
1132 bare hjkl already does; there is a real multi-selection to extend now.
1133 
1134 **The anchor never moves.** `network_extend` walks the region's FAR corner and
1135 leaves the anchor where it is, exactly as a drag does, so `shift+l` then
1136 `shift+h` returns to where it started rather than walking the whole region
1137 right and back. A far corner that meets the anchor again drops the expanse
1138 outright, so a region shrunk to nothing is the plain one-cell cursor and not a
1139 1×1 region that merely behaves like one — and carrying on past the anchor grows
1140 it the other way. Extending from a cursor that sits ON a node keeps that node
1141 selected, the anchor's cell being part of its own region, which is what makes
1142 the family an extend rather than a second way to start a selection.
1143 
1144 It scrolls the FAR cell into view (`keep_cell_in_view`, which
1145 `keep_cursor_in_view` is now a one-line wrapper of): the anchor is the end that
1146 is not moving, and following it would scroll the wrong end of the selection
1147 into view.
1148 
1149 **Frame All fits the name labels, not just the bodies.** A label hangs off
1150 its node's right edge (`Graph::node_labels`: an 8 px gap and a 14 px font,
1151 both scaled with the body against its 80 px baseline, the font clamped to
1152 6..48), so framing the bodies alone cut the right-hand column's names off.
1153 `State::node_extent` repeats that rule — the widget offers no query for it —
1154 using the widget's own `TextLabel::estimate_width`, the number it culls the
1155 label against, so the two cannot disagree. The fit is iterated rather than
1156 solved once, because the extent is not linear in the zoom: the font floor and
1157 the width's rounding mean a fit computed at 100% overstates what a small zoom
1158 saves. `frame_all_keeps_the_node_labels_inside_the_pane` is the check, and it
1159 fails on the body-only fit.
1160 
1161 Two chords moved to make room, both caught by `command::conflicts` rather than
1162 by hand: `edit_handles` from `Ctrl+H` to `Ctrl+Shift+H` (the ctrl+hjkl family
1163 owns those now), and `f` now frames the CURSOR where it used to frame
1164 everything, with framing everything on `shift+f` — the plugin's split.
1165 
1166 ### The network grid is a lattice, and a node sits on a crossing
1167 
1168 The network grid has ONE size per axis: `style.surface.graph.spacing_x` /
1169 `spacing_y` in config.kdl, the pitch — the distance from the centre of one
1170 grid line to the centre of the next. A node's `position` (col, row) names a
1171 lattice intersection, and the node body is CENTRED on it. The body has a
1172 size of its own, `style.surface.graph.node.width` / `height`, which the
1173 pitch does not touch: a denser grid moves nodes closer, it does not shrink
1174 them (a first cut derived the body from the pitch; it was disconnected the
1175 same day). Until 2026-09-22 the grid was rounded CELLS with grout between
1176 them, configured as a cell size (also the node size) plus a gap, and a node
1177 filled its cell.
1178 
1179 **Which file sets the pitch is easy to get wrong.** cce-ui merges the
1180 per-app override `~/.config/cce/cce-designer/config.kdl` OVER the main
1181 `~/.config/cce/config.kdl`, key by key, so a `spacing_x` in the per-app
1182 file wins over any edit to the main one — a whole afternoon of "the grid
1183 size is not changing" (2026-09-22) was a stale `spacing_x=71` in the
1184 override, left from the cell model. `get_state` over MCP reports `grid`
1185 (the live pitch and node size, the zoom percent, and the CONFIGURED pitch
1186 and node size), which is the one way to check from outside that a config
1187 edit reached the lattice.
1188 
1189 `State::grid_pitch_x` / `grid_pitch_y` and `node_w` / `node_h` are the
1190 zoomed geometry — `configured_grid_geometry` at 100%, scaled TOGETHER by
1191 `scale_grid_geometry`, which is the only relation between them; there is no
1192 other grid geometry on `State`. `cell_center`, `cell_rect` and `cell_at` are
1193 the three derivations every consumer goes through — the cursor outline, the
1194 click-to-cell of an empty-space press (`round`, not `floor`, because a cell
1195 is centred on its crossing and a click between two nodes belongs to the
1196 nearer), Frame Cursor, the zoom anchor. The configured geometry is the 100%
1197 baseline Reset Zoom returns to and Frame All scales down from (never past
1198 100%); `MIN_PITCH_*` / `MAX_PITCH_*` are the old node-body zoom limits
1199 expressed on the pitch.
1200 
1201 The widget paints the lattice as lines (`paint_grid`: gap colour, network
1202 opacity, `style.surface.graph.line_width` px, each line centred on its
1203 coordinate so the width changes nothing about where anything sits) with the
1204 two lines through the (0, 0) crossing heavier as the origin axes. Its
1205 cell-and-gap setters (`set_grid_sizes` / `set_skipped_sizes`) survive as a
1206 description of the same lattice for cce-files and cce-graph, which still
1207 speak it; this app sets the pitch.
1208 
1209 ### The cursor is a region, and dragging the grid grows it
1210 
1211 A left press on EMPTY grid puts the cursor on the pressed cell — on the press,
1212 not the release — and arms an expansion drag from it. Dragging grows the cursor
1213 from that anchor to the cell under the pointer. `State::grid_cursor_region` is the one derivation,
1214 `(col, row, cols, rows)`, never smaller than one cell; `grid_cursor_rect` is the
1215 window-space union the outline is painted on, which for the usual one-cell
1216 cursor is exactly `cell_rect` of it.
1217 
1218 **The release SETTLES the region** onto what it caught
1219 (`settle_cursor_expansion`): the bounding box of the selected nodes, or — with
1220 nothing caught — one cell at the MIDDLE of where the region stood, even spans
1221 rounding down toward its first cell. A region is a way of pointing at nodes,
1222 and once the pointing is done the empty margin the pointer swept through is
1223 noise: it hides nothing, it selects nothing, and it leaves the next alt+hjkl or
1224 Add Node reading off an anchor out in open grid. Settling also makes the region
1225 say what was selected — a box drawn loosely around two nodes comes back fitted
1226 to them. Nothing caught settles to the middle rather than back to the anchor,
1227 because the anchor is merely where the gesture began and a drag that selected
1228 nothing is aimed at the space it ended up circling.
1229 
1230 The SELECTION never changes in a settle — the bounding box of the selected
1231 nodes contains no cell the region did not — which is what lets it run at the
1232 end of every drag without a thought for what it might drop.
1233 
1234 **The region collapses by itself.** `grid_cursor_expanse` stores the anchor
1235 alongside the far cell, and `grid_cursor_region` hands it back only while that
1236 anchor is still `(grid_cursor_col, grid_cursor_row)`. So every OTHER way the
1237 cursor moves — a nav key, a click, a load, the selection following a node —
1238 leaves the anchor behind and drops the region with it, without a line in any of
1239 those places. Fifteen call sites write the cursor; a flag reset by hand at all
1240 of them is a flag that gets missed at one, and a cursor left stretched across
1241 the sheet is not a subtle wrong.
1242 
1243 **An expanded cursor selects every node standing inside it.**
1244 `State::selected_slots` is the selection, and it has two arms for a reason:
1245 one cell — the ordinary cursor — DEFERS to the graph's own `selected_node`,
1246 so nothing about a single selection changes (that one answer already carries
1247 the deselect memory, a click that arrived from another pane, and a selection
1248 made while the network was not focused); an expanded cursor names every node
1249 on a cell it covers instead. Its anchor is empty grid by construction — a
1250 press on a node drags the node — so there is no single selection to defer to.
1251 
1252 The network's operations act on that selection: **Delete**, the **`e`**
1253 geometry toggle, **Ctrl+C/X**, **alt+hjkl**, and the **mouse**. Two rules worth keeping:
1254 deletions run HIGHEST SLOT FIRST, or removing one shifts the slots above it
1255 and the second removal takes the wrong node; and the `e` toggle sets the whole
1256 selection to the opposite of the FIRST node's flag rather than flipping each,
1257 because a toggle over a mixed selection should settle it, not shuffle it.
1258 `network_move_node` moves the region along with the nodes — stepping the
1259 anchor alone is precisely what collapses a region, so the first alt+h would
1260 otherwise drop the selection it had just moved. The clipboard is a `Vec`, and
1261 a paste keeps the SHAPE it was copied in: the set's top-left lands on the
1262 cursor and each node keeps its offset, with a node whose cell is taken
1263 stepping aside to the nearest free one.
1264 
1265 **Dragging a selected node carries the whole selection** (`NodeDragGroup`,
1266 `drag_group_to`). The widget drags ONE node — it has one `dragging_idx` — so
1267 the companions are moved here, rigidly, by the offset the dragged node has
1268 travelled, measured from the cells they started on rather than stepped each
1269 frame (a drag is continuous but resolves to whole cells, so accumulating the
1270 steps would drift the group apart the first time two motions named one cell).
1271 They are NOT walked off occupied cells the way the widget walks the node it
1272 drags: a selection that rearranged itself around whatever it passed over would
1273 not be the selection you picked up — the same bargain alt+hjkl has always made.
1274 The preview follows `drop_target_cell_rect`, which runs `commit_drag`'s own
1275 resolution, and the release re-lays them from the cell that actually committed,
1276 since the widget can walk the dragged node a cell aside from the preview.
1277 
1278 Two things make that gesture work at all. **A press on a node inside the
1279 selection leaves the cursor alone**: the press path otherwise moves the anchor
1280 onto the pressed node, which is exactly what collapses a region, so the
1281 selection would be gone before the drag began. A press on a node OUTSIDE the
1282 selection does move it, and that collapse is the right one — clicking an
1283 unselected node selects that node. And `read_panel_offsets` returns early while
1284 a group drag is live, for the same reason: it yanks the cursor onto the
1285 selected node's cell, and the anchor is deliberately standing still. On release
1286 the region is shifted by the committed offset, as alt+hjkl shifts it.
1287 
1288 Escape collapses the region (`deselect_node`), because of the two selections
1289 this is the one that needs clearing: a single selection under a plain cursor
1290 comes back on the next sync anyway, while a region stands until the cursor is
1291 moved off its anchor.
1292 
1293 Everything that reads the cursor as ONE CELL still reads the anchor: Add Node
1294 places there, Frame Cursor centres it, `sync_cursor_and_selection` sets the
1295 graph's own selection from it. That single selection is deliberately NOT set
1296 from the region: `read_panel_offsets` yanks the cursor onto the selected
1297 node's cell, which would move the anchor off its own region and collapse it
1298 on the next layout sync. So with a region up the params pane shows nothing —
1299 it shows one node's parameters, and the selection is many.
1300 
1301 The paint reads the same `grid_cursor_covers`: a node body is recognised by
1302 the cell it is centred on and drawn with the highlight tint the widget gives
1303 its own single selection, rather than by a second rect test that could
1304 disagree with the selection itself.
1305 
1306 Arming is gated on the graph NOT having taken the press (`widget_took`). The
1307 case that bites is a press on a PORT: it starts a connection and consumes the
1308 press without selecting anything, so the empty-grid arm would read it as bare
1309 lattice and then swallow every motion event — leaving the rubber-band line
1310 frozen at the port it started from. The gesture is otherwise uncontested,
1311 because `Graph::draggable` is true only while it is moving a node.
1312 
1313 ### Auto-layout
1314 
1315 `src/layout.rs` arranges a level's nodes from their wiring. The network is
1316 already a GRID — positions are integer cells and the keyboard cursor steps cell
1317 by cell — so this is a layered assignment on cells, not a force-directed
1318 sprawl: a node's ROW is how far downstream it is, its COLUMN is chosen to sit
1319 under what it reads from.
1320 
1321 **Edges come from the same rule the wires do** — a node's `Input` parameter
1322 naming another node, which is the widget's `wire_pairs` derivation. Matching it
1323 is the point: a layout computed from relationships you cannot see would move
1324 nodes for reasons that are not on screen. It also means a second operand (a
1325 Boolean's `With`, a Copy's target) does not pull on the layout, because it does
1326 not draw a wire either. When those become wires they should become edges here
1327 in the same change.
1328 
1329 Flow is downward, matching every project in the repo (a Sphere at (4, 2)
1330 feeding an output at (4, 3)). Row is the LONGEST path from a root, not the
1331 shortest, so a node always sits below every one of its inputs rather than
1332 beside one of them. Depth is computed by iterating to a fixed point rather than
1333 by recursion, because a name-wired graph can be cyclic — A reads B reads A is
1334 something a user can type — and the loop stops improving instead of
1335 overflowing the stack.
1336 
1337 Utility trees are pinned: the settings node lives where the user put it, and an
1338 "arrange everything" that relocated it would be a surprise every time. Their
1339 cells count as occupied so nothing lands on top of them. Within a row, a node
1340 wants its parent's column (a root wants the column it already has, which
1341 preserves the left-to-right order among independent chains) and takes the
1342 nearest free column to that, searching outward — so a chain stays perfectly
1343 vertical and a collision nudges one node aside instead of shifting the whole
1344 row.
1345 
1346 `arrange` returns only the nodes that MOVED, so `layout_current_level` can say
1347 "moved 3 nodes" or "every node was already in place" — an arrange that did
1348 nothing because the layout was already right looks identical to a broken one,
1349 and the status line is the only thing that separates them.
1350 
1351 The command is `layout_nodes` on `Ctrl+Shift+L` rather than the bare `L`
1352 Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
1353 select family this app cannot implement until the Graph widget has
1354 multi-selection, so taking `Shift+L` now would have to be given back later.
1355 
1356 ### Display mode: the viewport menu, and smooth shading
1357 
1358 The viewport's right-click menu carries the DISPLAY MODE under Frame All
1359 and View 1:1: the Show Wireframe switch (its registry command), **Flat
1360 Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
1361 and the polygon **Opacity** as presets (`VIEWPORT_OPACITIES`, landing
1362 through `apply_setting("Geometry Opacity", …)` so the palette's row, the
1363 persist and the menu are one path — a menu cannot hold a slider, and an
1364 opacity set off the presets marks none of them). `viewport_menu_rows` and
1365 `run_viewport_menu_action` are split from the open and the click so a test
1366 reads and runs the rows.
1367 
1368 **Smooth shading is baked, not shaded.** The raster pass flat-shades every
1369 fill in `scene3d.wgsl` from screen-space derivative normals, and cce-ui's
1370 `Vertex3D` carries no normal. The light is fixed in WORLD space, though, so
1371 lighting each vertex from its smooth point normal and interpolating is
1372 exact: `geometry::smooth_lit_vertices` multiplies each corner's colour by
1373 `shade_factor(point_normals[p])`, and the fill draws with
1374 `SceneDraw::prelit` (cce-ui, 2026-09-24) so the shader does not shade it
1375 twice. `shade_factor` has to agree with the shader about which side is
1376 lit: the shader's normal is screen-right × framebuffer-DOWN, which for any
1377 visible surface points AWAY from the viewer — into the surface — so the
1378 bake uses `dot(-n_outward, l)`; on a plane the two modes give identical
1379 brightness (`smooth_shading_bakes_the_flat_shaders_light_per_vertex`). The
1380 lit copy is `State::scene_smooth_verts`, kept only while smooth is on; the
1381 path tracer keeps reading the unlit `rt_sphere_verts`, whose colours are
1382 its materials. Smoothing follows topology, so a welded mesh rounds off and
1383 a soup of unshared triangles stays faceted. Persisted as
1384 `render.smooth_shading` in state.kdl.
1385 
1386 ### Dragging the scene orbits the camera
1387 
1388 `State::orbit_camera_by` turns the camera by a drag delta, armed by a left
1389 press that `cursor_in_viewport` says landed on scene. Before it the camera had
1390 NO drag gesture at all: `Viewport3D` handles only `MouseWheel`, so the scene
1391 turned by scrolling and by nothing else — which suits a trackpad and leaves a
1392 mouse with no way to look around.
1393 
1394 `ORBIT_RADIANS_PER_PX` is the trackpad's own pixel-delta constant, so a drag
1395 and a two-finger swipe turn the scene at the same rate rather than feeling like
1396 two different cameras. The default camera carries its orbit in
1397 `rotation_x`/`rotation_y`; a NAMED camera accumulates into
1398 `pending_yaw`/`pending_pitch` for its node to pick up — the same split the
1399 scroll path makes, so a dragged camera and a scrolled one mean the same thing.
1400 A drag stops when the pointer does (`reset_velocity`), unlike a flicked scroll,
1401 which coasts.
1402 
1403 Precedence matters and is load-bearing. The press arms AFTER the viewer state's
1404 own press hook, so dragging a curve handle still edits it, and after the node
1405 hit tests, so a press on a node still moves the node. "Empty" means the scene
1406 really is what is under the cursor — which, with the network overlaying the
1407 window, is exactly what `in_network_pane`'s node test decides.
1408 
1409 **The active camera's name lives in two places, and `State::set_active_camera`
1410 is the only writer of either.** The viewport widget keeps its own copy because
1411 its wheel handler routes by it — the Default Camera's orbit lands on the
1412 widget's `rotation_x`/`rotation_y`, a camera node's accumulates into
1413 `pending_yaw`/`pending_pitch` for `tick_frame` to write onto the node — and
1414 until 2026-09-24 that copy was written once, at construction, from whichever
1415 project `State::new` loaded. Open a project whose active camera differed (the
1416 startup default-project pointer, Open, New, the viewport menu) and the two
1417 disagreed: the widget parked every wheel into the pending pair, the drain saw
1418 the Default Camera active and discarded it, and trackpad scrolling in the
1419 viewport did nothing while a drag — which reads `State`'s copy — still orbited.
1420 `a_wheel_orbits_the_camera_that_is_active_after_a_change` covers the three
1421 paths.
1422 
1423 ### Commands, chords and the palette
1424 
1425 `src/command.rs` is one list of everything the app can be asked to do. Each row
1426 carries its `id` (snake_case — this is what `input.kdl` binds, so it follows
1427 that file's existing convention and must not change when the label does), its
1428 `label` (what the palette and menus show), a `Context` (which pane it belongs
1429 to), a `Run` (how it reaches the work), and a `default_chord`.
1430 
1431 Before it there were three vocabularies with nothing holding them together: the
1432 `Action` enum matched against chords, the menu-item LABELS `execute_menu_action`
1433 dispatches on, and a hand-written registration block listing which `Action` got
1434 which chord. A command lived in whichever of them someone had needed, and
1435 nothing could tell you which ones had no binding at all. `ShortcutManager` now
1436 binds **command ids**, not `Action`s — which is also what lets a chord reach a
1437 menu-dispatched command like Open, something no binding could do before.
1438 
1439 `Run` has two variants because the app genuinely has two dispatch paths; a
1440 command names exactly one, so the palette, the chord and the menu all end up in
1441 the same code. `State::run_command(id)` is the single entry point, and it is
1442 exposed over MCP as `run_command` — every command is scriptable, including the
1443 ones no menu label reaches.
1444 
1445 **The toolkit's runner claims four chords before the app sees them**, from
1446 `input.kdl`'s `cce-ui` domain: `undo` (ctrl+z), `redo` (ctrl+shift+z),
1447 `focus_next_group` (ctrl+tab) and `focus_prev_group` (ctrl+shift+tab). Undo and
1448 Redo are therefore registry rows with NO default chord — not an oversight: the
1449 runner routes them to the focused widget first, so a text box undoes its own
1450 typing before the app is asked, and registering ctrl+z here would quietly take
1451 that away. Focus Next/Previous Pane do override the runner's group chords, which
1452 is deliberate and predates the registry. `command::conflicts` cannot see any of
1453 this — it compares this app's bindings with each other — so it is written down
1454 here instead.
1455 
1456 `conflicts()` reports two commands resolving to one chord at startup, because
1457 the failure is otherwise silent and looks like a broken command rather than a
1458 broken binding: `match_command` returns the first match and the second simply
1459 never runs. It compares chords as PARSED, not as text. That exposed a real bug:
1460 `Shortcut`'s derived `PartialEq` compared character keys byte for byte while
1461 `matches()` compared them case-insensitively, so `Ctrl+S` and `Ctrl+s` were one
1462 keypress at the keyboard and two distinct values in memory — and the collision
1463 detector quietly failed to report exactly the collision it exists to catch. Both
1464 now go through one `same_key`.
1465 
1466 **The palette** is the dialog's Commands half (`src/dialog.rs`, below), not a
1467 widget of its own. Ranking is `fuzzy_rank`, which reproduces the plugin's
1468 fuzzyfinder exactly — shortest contiguous span, then earliest start, then
1469 alphabetical — so muscle memory survives; the focused pane's commands are then
1470 partitioned to the front, stably, without dropping anything (a palette that
1471 hides what you are looking for is worse than one that lists it second). Each row
1472 carries its chord in a column of its own, so the palette teaches the keyboard
1473 rather than replacing it.
1474 
1475 It was a `cce-cloud --dmenu` popup until 2026-09-19: a second PROCESS with its
1476 own window, handed one line of text per row on stdin and answering with one line
1477 on stdout. Everything awkward about it followed from that pipe — the chord had
1478 to be padded into the label to fake a column (a tab rendered as one literal
1479 stop, so they came out ragged), and the answer had to be matched back to a
1480 command by the LONGEST label the row starts with, since "Save" is a prefix of
1481 "Save As"'s row. `palette_row` / `from_palette_row` were that encode/decode pair
1482 and are gone with it; `fuzzy_rank` and `palette_entries` survive, because the
1483 ranking was never the problem.
1484 
1485 `command_palette` (Ctrl+P) and `toggle_dialog` (Alt+D) both reach the same
1486 dialog and differ in exactly one way, which is the reason both rows exist:
1487 Ctrl+P OPENS it (with a fresh query, never closing), Alt+D toggles it.
1488 
1489 `test_every_menu_command_names_a_label_that_is_dispatched` scans `app.rs` for
1490 `execute_menu_action`'s arms. Scanning source is an odd way to assert it, but
1491 the alternative is calling every command to see whether it is handled, and
1492 "Exit" would end the test run. It is the check the plugin's `hccommands.py` doc
1493 argues for: a label kept in two places drifts, and a renamed one fails silently
1494 — the dispatch falls through its match and the command does nothing.
1495 
1496 ### The dialog (Alt+D, Ctrl+P, Tab)
1497 
1498 `src/dialog.rs` is the app's one modal overlay, and **every filterable list
1499 in the designer is an opening of it**. It is one roster slot, `DIALOG_IDX`,
1500 an app-owned `Dialog` that paints the plate, the query line and the row
1501 list — and the rows' controls, from the toolkit's own stamps (`Toggle`,
1502 `Slider`) and hosted `ColorSelector`s, so a slider in the dialog is the same
1503 slider as a slider in the params pane.
1504 
1505 `Mode` says what an opening is for, and it is the reason there is one widget
1506 rather than two:
1507 
1508 - `Mode::Commands` (**Alt+D**, **Ctrl+P**) — ONE list: every registry
1509   command, fuzzy-filtered in place, and every display setting
1510   `DesignSettings` persists, ranked among them. Until 2026-09-24 the
1511   settings were a second HALF behind a tab strip, a second `ParametersBg`
1512   slot (`DIALOG_PARAMS_IDX`) laid out inside the plate with section headers
1513   and no filter. A setting is something you ask for by name exactly as a
1514   command is, so it ranks in the same list; the strip, its two labels, the
1515   section rows and the second slot are gone, and with them the double
1516   paint, the `dispatch_uncovered` routing into a second slot and the
1517   `dialog_settings_shown` baseline the writeback diffed against.
1518 - `Mode::AddNode` (**Tab**, in the network pane) — one list of node
1519   templates, and a pick that instantiates at the grid cursor. Tab is what
1520   opened it, so Tab closes it again. The query hint names the mode; there
1521   is no title band, so the two openings are the same plate.
1522 
1523 Both modes share the plate, the keys and `fuzzy_rank`, which is the whole
1524 point — the app used to put two filterable lists in front of the user that
1525 looked and behaved nothing alike.
1526 
1527 **A row's control is `Row::control`, an `Option<Control>`**, and a row that
1528 has one is worked IN PLACE — the dialog stays up, the control re-reads, the
1529 selection stays where it was:
1530 
1531 - `Toggle` — a toggle command's switch (`command_toggle_state` is the
1532   table, the same read the View menu's checkmarks are set from), painted as
1533   the toolkit's `Toggle` in a right-hand column reserved for every row as
1534   soon as any row has one, so the chord column keeps a straight edge. Enter
1535   or a click flips it. `dialog_toggle_rows_cover_every_toggle_command` fails
1536   when a `toggle_*` / `show_*_pane` command is added without an arm in the
1537   table, because the miss is silent — the row just ships plain.
1538 - `Slider` — a value over a range, to `dec` decimals; `dec` 0 snaps to
1539   whole numbers, which is the spinbox shape (Grid Thickness in thousandths,
1540   Origin Size in tenths — the units those params always used). One toolkit
1541   `Slider` stamp in a `RefCell` serves every slider row, set to each row's
1542   range and value as it is painted. The band **begins `SLIDER_W` in from the
1543   row's right end and runs out to the CHORD column's right edge**, so it
1544   ends where every other row's key binding ends and the switch column stays
1545   clear; the readout sits AHEAD of the band, and a press tests the band
1546   alone — over the whole control a click on the readout would jump the
1547   value to whichever end of the range it abuts. A press jumps to the
1548   pointer and arms the app's widget-drag protocol on `DIALOG_IDX`
1549   (`Dialog::draggable` / `drag_*`), so the value follows the pointer off the
1550   plate; the wheel over the control turns it (2% of the range a notch) where
1551   over the rest of the list it scrolls; Left/Right nudge it by the row's
1552   `step` while it is selected; Enter on it runs nothing. The **zoom row**
1553   (`ZOOM_ROW_ID`, only while the network pane is focused, since zoom is that
1554   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
1555   range the pitch limits), landing through `set_zoom_percent`, which zooms
1556   about the cursor cell and re-reads the row, since `zoom` clamps.
1557 - `Choice` — a fixed set (World Unit). The current option reads in the
1558   chord column between two arrows; Enter or a click steps to the next,
1559   Left/Right either way.
1560 - `Color` — a hex colour, with or without alpha. Behind each colour row the
1561   dialog keeps one toolkit `ColorSelector` (`Dialog::colors`, by row id,
1562   kept across re-rankings so a query that drops the row does not kill its
1563   picker): a real widget, not a stamp, because it carries state — a hex
1564   edit in progress, a `cce-color-editor` process streaming values. It is
1565   painted over the band and handed presses on the band with the band as its
1566   rect (`color_event`); `Dialog::tick` polls it; a change comes out of
1567   `take_color_changes`. **While its hex well is being typed into it has the
1568   keyboard ahead of everything** — `dialog_key_input` forwards to
1569   `editing_color` first, so Escape and Enter end the edit rather than the
1570   dialog.
1571 
1572 **A setting row edits the live field** — see "There are no meta nodes"
1573 above, which is where these values used to live and why a direct write did
1574 not stick. `SETTINGS` is the table of which row belongs to which owner:
1575 `Owner::Field` (a live field, with a `Ctl` saying what control draws it,
1576 since a bare Rust field carries no type or range the way a param did) or
1577 `Owner::ActiveCamera` (an active-camera param with the live field as its
1578 fallback — the Default Camera has no node). The toggles the retired
1579 subnets held are NOT rows of the table: each is a registry command with a
1580 switch on its own row, and a second row per toggle would have listed every
1581 switch twice. A row's id is its label under `SETTING_ROW_PREFIX`
1582 (`setting_of_row` resolves it back), its control is built by
1583 `setting_control` from the value `setting_value` reads (the params pane's
1584 encodings — a hex, a whole number in the spin's unit, an option's text),
1585 and every change lands through `apply_setting(label, value)`: write to the
1586 owner, then the one regenerate-and-persist pass (the viewport meshes bake
1587 their sizes and colours in) and `refresh_dialog_controls`, which re-reads
1588 every control in place — not `refresh_dialog_rows`, which re-ranks and
1589 would throw the selection to the top. `dialog_settings_rows_name_owners_that_exist`
1590 is the backstop, because the failure is silent — a `Field` key no dispatch
1591 arm names reads a default and writes nowhere, so the row draws, takes an
1592 edit and does nothing, which is why that test round-trips every one of them.
1593 **Group Marker Scale** is the one row added with the collapse: the
1594 Selected-Group markers' radius as a multiple of Point Size
1595 (`State::group_marker_scale`, persisted in the render block; 1.25 was the
1596 hard-coded ratio).
1597 
1598 **The open project's PATH heads the Commands list**, as a row rather than a
1599 command (`PATH_ROW_ID`): the label is the path, the chord column carries the
1600 file name — the palette's readout of what is being edited, in the column a
1601 command's chord would use — and picking it copies the path to the clipboard
1602 and closes, a copy being done the moment it happens. It ranks against the
1603 path text like any other row, so a query finds or drops it.
1604 
1605 Two details. The label truncates on the LEFT (`Row::truncate_head`, the
1606 paint's `fit_head`), because the tail of a path is what identifies it and a
1607 row cut down to `/home/me/pro...` would name every project in the directory
1608 equally badly. And there is NO row when no project is loaded: the bundled
1609 `default_project.json` leaves `loaded_project_path` None on purpose (the
1610 window title and Set As Default take the same position), and a row offering
1611 to copy a path into a versioned file in the source tree would be a trap.
1612 `project_path_readout` reads the name with `file_name()`, the same call the
1613 window title makes, so the two cannot disagree about what is open.
1614 
1615 The actual clipboard write is `#[cfg(not(test))]`. `wl-copy` has to OUTLIVE
1616 its caller to serve the selection, and it inherits the test binary's captured
1617 stdout — so a test that really copied left cargo waiting on a pipe held open
1618 by a clipboard daemon, which looks exactly like a hung suite.
1619 
1620 **Alt+D, not Super+D.** Every Super chord is the compositor's before any client
1621 sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
1622 launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
1623 app's own — the `move_*` family already lives there.
1624 
1625 **The dialog plate compresses its backdrop harder than anything else**
1626 (`State::dialog_compression`, `style.surface.dialog.compression` in
1627 config.kdl, default `DIALOG_COMPRESSION` 0.8 against a menu's 0.6). The
1628 plate's own compression can be 0 — a config that keeps the panes clear —
1629 and at that value the rows sat on the full-contrast scene and network. The
1630 render arm paints the plate itself with `dialog::plate_material` rather
1631 than through `append_widget_plate`, whose material comes from the fill alone,
1632 exactly as the node bodies do for `node_compression`. Opaque plates (blur
1633 off) have no backdrop and ignore it.
1634 
1635 **The dialog is painted after the overlay passes, not in the widget walk.** A
1636 high `z_order` is not enough: `append_frame_text`, `append_scale_readout` and the
1637 point-number overlay all run AFTER the whole walk, so the graph's node labels drew
1638 straight over a dialog that had already covered them. `append_dialog` runs
1639 between the plate corners and the context menu instead.
1640 
1641 **And even that is not enough, because text is not painted in display-list
1642 order.** The engine collects every `Prim::Text` and lays them all out at the end,
1643 so a plate over a label does not hide it at any depth. What hides it is the
1644 engine's popover-occlusion clamp, which reads `UiContext::active_popovers` — so
1645 `Dialog::popover` claims the dialog's whole rect, and the designer's
1646 registration loop picks it up. The clamp exempts text whose own bounds COINCIDE
1647 with the occluder, so every label inside the dialog carries the dialog's rect and
1648 truncates itself; a hosted colour selector is painted twice for this (once
1649 for its well and swatch, once into a scratch `PaintCtx` whose text alone is
1650 re-emitted retagged), since a `PaintCtx` can be handed text back but not
1651 geometry.
1652 
1653 **`Dialog::occluding` exists because that one claim serves two mechanisms that
1654 want opposite answers.** `UiContext::is_coordinate_covered` reads the same
1655 `popover_rect` — off every REGISTERED widget, not just the ones in
1656 `active_popovers` — to decide a press landed under something else. With the claim
1657 standing, every control inside the plate is covered by the plate it is drawn
1658 on and nothing can be clicked; the toggles looked laid out, painted, and
1659 completely inert. `State::dispatch_uncovered` lowers the flag for the length of a
1660 dispatch into the dialog and puts it back, invalidating the coverage memo on both
1661 edges (the engine queries it on every left press, so lowering the flag alone
1662 leaves a stale cached answer).
1663 
1664 Input is intercepted whole, at the top of `handle_event`'s keyboard and mouse
1665 branches: `dialog_key_input` is TOTAL rather than a layer, because the network
1666 pane's bare-letter family is ungated and typing "frame" into the filter would
1667 otherwise step the grid cursor four times and flip a node's geometry toggle on
1668 the way past. A press outside the plate dismisses and is swallowed, the way the
1669 node and plate-corner menus behave.
1670 
1671 **Nothing in this crate shells out to `cce-cloud` any more**, and
1672 `nothing_shells_out_to_cce_cloud_any_more` scans the source to keep it that
1673 way. Retiring the two popups took a surprising amount of scaffolding with
1674 them: `CloudPopupTracker` (the single-active-popup toggle bookkeeping), the
1675 `CloudSpawned` / `CloudClosed` events that adopted a popup's pid, the
1676 `RunCommand(&'static str)` event that existed because the popup ran on its own
1677 thread and could not touch `State`, and the `libc` dependency, whose only use
1678 was `kill`ing a stray popup. `active_menu_cloud_pid` / `_idx` and the
1679 `menu_closed` MCP tool went too — they were already dead, left from a retired
1680 attempt at menubar dropdowns over `cce-cloud`, and nothing had set them to
1681 `Some` in a long time. `cce_ui::process::CloudPopup` itself still exists; the
1682 designer was its only consumer, so it is now unused public API in a shared
1683 crate, which is a coordination job of its own.
1684 
1685 ### Runtime paths point into the source tree
1686 
1687 Node templates (`nodes/*.json`) and `default_project.json` are located via
1688 `env!("CARGO_MANIFEST_DIR")` — the installed binary still reads from the source
1689 checkout. Templates are resolved recursively: a template's children reference other
1690 templates by `type`, merged with param overrides (`load_fs_tree` in `src/app.rs`).
1691 Missing referenced templates panic at load.
1692 
1693 Saved instances are self-contained copies, but the loader merges template
1694 evolution into them (`merge_template_defs` in `src/app.rs`, run on every
1695 project deserialization including thumbnails): missing params are inserted
1696 where the template puts them (after the last template param the instance
1697 already has — so the Sphere's Method lands above Radius in an old save, not
1698 below Color), existing ones keep their value but take the template's UI
1699 metadata, and a subnet template (the Embryo, since the four kernel subnets
1700 went native) refreshes its children's params and any child's `Code`
1701 outright — **the template owns the surface and implementation, the
1702 instance owns its values.** A script hand-edited inside a template
1703 instance reverts on load; custom scripts belong in bare wrangle nodes,
1704 which the merge never touches.
1705 Native nodes match their template by type, subnet instances by name
1706 ("sphere3" → "Sphere", case-insensitively) plus a full child name/type match; the merge never
1707 injects or deletes children and never rewrites files on disk.
1708 
1709 ### Node names are lowercase and carry no whitespace
1710 
1711 A node's name is a segment of its path — `/sphere1/opencl1` is how the
1712 breadcrumb, the MCP tools and every `Input` wire name it — so names are
1713 lowercase, as Houdini's are, and carry no spaces (since 2026-09-21).
1714 `sanitize_node_name` (src/app.rs) is the rule: the conventional space
1715 between a template name and its index goes ("Sphere 1" → "sphere1", which
1716 is also what minting now produces), any other whitespace becomes an
1717 underscore ("My Region" → "my_region"), the whole thing is lowercased, and
1718 empty comes back as `node`, because a path convention with exceptions is two
1719 conventions. The template merge matches an instance to its template
1720 case-insensitively ("sphere3" → "Sphere"). It runs at every entry point — minting, the
1721 `add_node` name override, `rename_node` — and as a LOAD-TIME MIGRATION on
1722 every load path, `Project::sanitize_node_names`, called before the template
1723 merge in all five places a project is deserialized (the two `load_from_file`
1724 branches, `State::new`, the thumbnail and the export CLI).
1725 
1726 The migration follows references, because wires are by name: within each
1727 level it renames the children, then rewrites any sibling parameter whose
1728 value was one of the old names (`Input`, `With`, `Rest`, `Target`, `Source`,
1729 `Collider` — any of them, since it matches values rather than a list), and
1730 maps the view state's active camera, the one reference outside the tree. A
1731 sanitized name that lands on a sibling's ("Sphere 1" beside a hand-named
1732 "sphere1") steps aside with a `_2` suffix rather than leaving two nodes one
1733 name and every wire to them ambiguous.
1734 
1735 ## Repo hygiene
1736 
1737 `scratch/` holds ad-hoc debug scripts/logs and `screenshot*.png` at the root are
1738 debugging artifacts — not source, don't extend them. Tests live in
1739 `src/main.rs`'s `#[cfg(test)]` module; add new ones there. The exception is
1740 `tests/`, which holds the two tests that SCAN the crate's own source —
1741 `doc_claims.rs` (CLAUDE.md's `(~Nk lines)` figures) and `user_paths.rs` (below)
1742 — and they are out there because a scanner under `src/` is the first thing it
1743 finds. Both are deliberately mirrored per crate rather than shared, since every
1744 crate here is its own git repository that must build standalone; they need
1745 nothing but `std`, so copying one into a sibling is the whole job. Commit
1746 messages follow `feat:` / `fix:` / `refactor:` style (see `git log`).
1747 
1748 **`tests/user_paths.rs` refuses source that builds a path the user owns**, the
1749 class of bug that had this suite rewriting `~/.config/cce/cce-designer/state.kdl`
1750 on every run (see "App-written settings" above). Two rules, one per shape that
1751 actually shipped: no line assembles a config path out of `.config` by hand —
1752 `cce_ui::config::cce_config_dir()` is the only way in, because that is what the
1753 `cfg(test)` redirect keys off — and every `temp_dir()` is scoped with
1754 `std::process::id()` within a line or two, since /tmp is one namespace shared
1755 with every other user and every concurrent run. Verified by reintroducing each
1756 bug: both are caught, naming the file and line. The `temp_dir()` rule is not
1757 limited to tests, because a fixed /tmp name is no better in shipped code.
1758 
1759 Two runtime guards sit alongside it in `src/main.rs`, since a scan cannot see
1760 behaviour: `the_suite_does_not_write_the_users_own_settings` and
1761 `the_recent_files_list_is_not_the_users` each snapshot the real file, exercise
1762 the write path, and assert it did not move — the second checking the load side
1763 too, because reading the user's recent list would make the suite's behaviour
1764 depend on the machine.