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

CLAUDE.md (287.2K)

   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_menu::pane_detach_flag`), spawned by
  56   the plate 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 — a right press on
  61   that stub 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   The main window's reload of that channel takes the TREE and navigation only
  65   (`load_sync_channel(path, keep_own_view: true)`), never the view state a
  66   detached window wrote — that is the detached window's defaults, and applying
  67   it reset the main window's plate sizes, panes and camera on every autosave.
  68   The write side matches: a detached window's save keeps the view state
  69   already in the file and replaces only the navigation (`detached_view_state`),
  70   so the file always carries the main window's layout.
  71   Note that detaching REWRITES `default_project.json` in the source tree, since that
  72   file is the sync channel; it is versioned, so check `git status` after testing.
  73   **A detached pane window is a working satellite only since 2026-09-29.**
  74   Two things were missing for every pane but the circular network. The
  75   window never read the channel at startup: `State::new` seeds the tree,
  76   camera name, pan and path from the bundled file and records its mtime, so
  77   the window waited for a change the file it had just been handed was never
  78   going to have, and a detached parameters window opened with nothing
  79   selected — an empty pane (`seed_detached_window` takes the channel whole
  80   now). And the two places that ASK for an autosave named only the circular
  81   network, so with the parameters, spreadsheet or playbar detached neither
  82   window wrote the channel again after the detach; `State::syncing_windows`
  83   is the one test the requests, the poll and the exit save share.
  84   `a_detached_params_window_follows_the_selection_and_the_camera` covers
  85   both, and the camera that rides the same channel.
  86 
  87 ### MCP automation server
  88 
  89 The main window runs an embedded MCP server on `127.0.0.1:3001`
  90 (`CCE_DESIGNER_MCP_PORT` overrides so a second instance can run alongside;
  91 `src/api.rs`). This is the way to drive/inspect the running app: attach with
  92 `claude mcp add --transport http cce-designer http://127.0.0.1:3001/mcp`, or
  93 speak JSON-RPC directly with curl (`initialize` / `tools/list` / `tools/call`).
  94 There is one tool per `McpAction` variant (tool name = the variant's serde
  95 tag, dispatched in `apply_mcp_call` in `src/window.rs`) plus `get_state`,
  96 which returns the project beside three app-state fields that are not part of
  97 the save: `playbar`, `grid` and `status` — the status line's text as shown,
  98 which is the way to read a load report, a node error or a refused edit when
  99 the window is off-screen. The
 100 tool list lives in `mcp_tools()` in `src/api.rs`; the protocol layer is
 101 `cce_ui::mcp` (tools-only Streamable HTTP). Keep the enum, the tool list, and
 102 the schemas in sync — `test_mcp_tools_map_to_actions` enforces the mapping.
 103 **`menu_click` dispatches three menus and refuses the rest** (since
 104 2026-09-29). The five menubars are roster slots that are never drawn —
 105 `HEADER_H` and `MENUBAR_H` are 0 — kept as pane identities and for their
 106 checkmarks, so the MCP tool is the only thing that can click one. What
 107 `process_window_event` still dispatches by index is what no registry command
 108 was then: the viewport menubar's Camera menu, and the parameters menubar's
 109 Preset and Reset (`window::menu_is_dispatched`). Everything else they list is
 110 a command, reached through `run_command`; a click on it is an error saying so,
 111 where it used to be accepted and, the item lists having drifted from the
 112 matches, ran the wrong item (the header's Save opened a project).
 113 `a_menubar_click_is_dispatched_or_refused` is the test.
 114 
 115 **The cameras and the parameter reset are commands too** (the same day),
 116 so the palette reaches them and a chord can: `next_camera` /
 117 `previous_camera` step through `State::camera_names` (the Default Camera,
 118 then the root's camera nodes — see "The root is the object level") and wrap, `default_camera` goes back to it,
 119 and each camera NODE is a row of the palette — `Camera: camera1`, id under
 120 `CAMERA_ROW_PREFIX`, ranked among the commands as the viewport's, with
 121 `active` in the chord column of the one in use. They are rows and not
 122 registry commands because they are nodes, as the recent projects are paths.
 123 `State::choose_camera` is the one entry. `reset_parameters` runs
 124 `State::reset_parameters` on the node the params pane shows, from
 125 `template_default` — so a child inside a subnet instance resets to the
 126 subnet template's override, where the menubar's arm looked the template up
 127 by node type and missed it. All four ship unbound, and the three menubar
 128 menus run the same functions. **The Custom preset is retired**: it was the
 129 template's defaults with their numbers scaled by half again, a stand-in
 130 that stored nothing and read nothing of the node's own values.
 131 `the_cameras_and_the_parameter_reset_are_commands` is the test.
 132 
 133 **Edits to the node tree are undoable** (`src/edit_history.rs`, the same
 134 day), the first thing outside a text box or a viewer state that is.
 135 `State::edit_history` is ONE stack of two kinds of step, so that undo takes
 136 them back in the order they were made:
 137 
 138 - **Parameters**: the parameters of one node that CHANGED, as they stood.
 139   Recorded by the writers, through `State::record_params` — the params
 140   pane's write-back (`sync_parameters_to_project`), the row menu
 141   (`run_param_action`, whose work is `run_param_action_unrecorded`), MCP's
 142   `set_param`, and Reset Parameters.
 143 - **Structure**: nodes added, removed, moved and renamed, wires made and
 144   broken, the display and bypass flags. Recorded by NOTICING:
 145   `record_structure_changes` compares the tree with how it stood at the
 146   last look (`State::structure_base`) and what differs is the step. It
 147   runs at the end of `process_window_event` and of `apply_action`, and
 148   ahead of every undo and every parameter record, so nothing done is left
 149   unlooked at. There are a dozen writers of the graph — the widget's drag
 150   read back by `read_panel_offsets`, the keyboard families, paste, the
 151   palette's pick, MCP, the image commands, Arrange — and a recording call
 152   in each is one the thirteenth would not make. **A new writer of the
 153   graph needs nothing.**
 154 
 155 The rules:
 156 
 157 - **A step holds what changed and nothing else.** A parameter step names
 158   its parameters; a structure step names its nodes, by id, and of a node
 159   that stayed only its position, its two flags and its wires (the
 160   parameters of the `node` kind). So what is written without being
 161   recorded is left as it is by an undo: a camera node's Rotation under an
 162   orbit, a curve's Points under its handles (which have their own history
 163   while the viewer state lasts), View 1:1, the template merge. A removed
 164   node is kept whole, children and all, and comes back at the place it had
 165   among its siblings.
 166 - **What replaces the tree is not an edit.** New Project and Open clear
 167   the history and drop the base; the sync channel's reload drops the base
 168   and keeps the history. A level whose children cannot be told apart by id
 169   (a hand-built tree with empty ids) is not followed.
 170 - **One step per gesture.** Records that share a group are one step until
 171   the group is broken: by any press or release, by Enter, Tab or Escape
 172   (all at the top of `handle_event`), or by `GROUP_IDLE` (a second) with
 173   nothing recorded. The pane's group is the node and the parameters
 174   changed; the graph's is a MOVE of the same nodes, so a run of alt+hjkl is
 175   one step. The graph is not looked at while a drag is held
 176   (`gesture_held`), so a dragged node is one step from where it was picked
 177   up.
 178 - **Slots are held by id across a structure step**: the editor's path
 179   and the selection, which a node coming or going would move. An editor
 180   inside a node that an undo takes out comes up to where the node was.
 181 - **Undo and Redo consult it LAST** — a code row, then a viewer state,
 182   then this (`Application::undo` for the chord, `Action::Undo` for the
 183   palette) — so the order ACROSS the three is by owner and not by time. A
 184   focused text box is ahead of all of them, in the toolkit's runner.
 185 - **A rename is put back by renaming.** A structure step holds the
 186   nodes renamed as (id, the name it was), and `restore` runs
 187   `rename_node_in_tree` on each, so the wires and the expression paths
 188   that name the node, anywhere in the tree, are written back with it —
 189   writing the name alone would leave them naming nothing. The active
 190   camera is held by id across the step. The names go back AFTER the wires
 191   the step holds: the other way round, what is filed for redo is those
 192   wires as the rename had just left them, and redo puts the old name on
 193   them. A name a sibling has taken since is not taken twice; the node
 194   keeps the one it has.
 195 - It is not cce-ui's `History` because that has no way to look at a step
 196   before taking it, and what is filed for redo is the current state of
 197   what the step names.
 198 
 199 `the_graph_is_undone_a_step_at_a_time`,
 200 `a_rename_is_undone_with_what_names_the_node`,
 201 `a_parameter_edit_is_undone_a_gesture_at_a_time` and
 202 `reset_parameters_is_undone_and_redone` are the tests.
 203 
 204 (The former bespoke HTTP API on port 3000 was retired in favor of this;
 205 app-internal threads like the cce-files choosers now return results via
 206 `CustomEvent::RunAction` instead of POSTing to it.)
 207 
 208 ## Architecture
 209 
 210 The designer runs on cce-ui's standard `Application` trait / `engine::run` pattern
 211 (`src/application.rs` holds the impl; the engine owns the Wayland plumbing, calloop
 212 loop, and the `VkRenderer`). Because it draws a 3D scene and shapes its own text, it
 213 uses the engine's extended hooks — it is the reference consumer for them:
 214 `renderer_init` (create persistent meshes), `stage_renderer` (flush pending mesh
 215 updates, stage the raster scene / RT pane; returns true while the path tracer
 216 refines), `handle_resize`, and `standard_csd` / `cursor_icon` / `take_window_action`
 217 (the detached circular window's radial border resize + top-arc move). The 2D frame —
 218 geometry AND text — is the engine's single paint path: `display_list` returns
 219 `State::collect_display_list()` and `display_list_text` opts the text into the
 220 engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its own
 221 — the standalone `vk-smoke` bin is the one place that keeps its own, reached through
 222 `cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
 223 gone from cce-ui with the wgpu path).
 224 
 225 - `src/app.rs` (~13.1k lines) — the heart: `State` (the entire app model), `McpAction` /
 226   `CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
 227   config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
 228   two halves of the old render loop. GPU mesh updates are staged CPU-side
 229   (`pending_*` fields, `spheres_dirty`) and flushed in `stage_frame` because only
 230   the engine hooks see the renderer.
 231 - `src/slots.rs` — the widget roster. Top-level widgets live in fixed slots on
 232   `WidgetSlots` addressed by `*_IDX` constants (`VIEWPORT_IDX`, `PARAM_IDX`,
 233   `NETWORK_PANEL_IDX`, … up to `WIDGET_COUNT`) rather than a dynamic tree; every slot
 234   is statically typed, and index-driven paths (draw order, focus cycling, broadcast
 235   loops) go through `get_dyn`/`get_dyn_mut`. The roster is declared once, as one line
 236   per slot in the `widget_roster!` macro invocation (`INDEX_CONST: field: WidgetType`),
 237   which generates the constants, `WIDGET_COUNT`, the struct fields and all four
 238   dispatch matches — adding a pane is that one line. The typed accessors that assert a
 239   slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
 240   `State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
 241   slot-only widgets, are also here.
 242 - `src/dialog.rs` — the Alt+D dialog: the `Dialog` widget (a third app-owned
 243   slot-only widget) plus the `State` half that fills it, routes its input and
 244   writes its settings back. See "The dialog (Alt+D)" below — three of its four
 245   hard parts are about paint order and occlusion, none of which is guessable
 246   from the widget.
 247 - `src/plate_menu.rs` — the plate menu: what can be done to a pane's PLATE
 248   (`PLATE_SLOTS` — network, params, spreadsheet, playbar; NOT the viewport,
 249   whose plate is the window-spanning lip) — Collapse/Expand, Detach/Reattach,
 250   and nothing else (see "There are no docks": Full Width, the dock tabs and
 251   Move To went on 2026-10-07). The network's and the params HUD's are
 252   Detach alone, as neither collapses, and the
 253   network's are in no menu: its Plate page went on 2026-10-07, Detach being the
 254   `detach_circular_window` command in the palette. `plate_menu_rows(idx)` is the
 255   one list. **There is no corner trigger** (since 2026-10-01; it was a small
 256   circle on each plate's top-right, which opened these rows as a menu of their
 257   own and, DRAGGED, moved the pane to another dock — the drag, its drop
 258   highlight and `AppDrag::DockDrag` went with it, and Move To was the rows'
 259   replacement until the docks went). The rows are in each plate's RIGHT-CLICK menu: a **Plate**
 260   PAGE row (see "Page rows" below) at the foot of the playbar's menu
 261   (`PlaybarMenuAction::PlatePage`), which turns the menu into them under a
 262   band back to it (`open_plate_page`; until 2026-10-06 they were appended
 263   inline there), and the whole menu where a pane has none of its own — the
 264   params pane off a row, the spreadsheet (`open_plate_menu`, at the
 265   pointer). A row from any of them runs through
 266   `run_plate_menu_action`; a Plate page's band goes back to the menu it was
 267   turned from (`State::plate_page_from`). Collapse shrinks a plate to its title stub via
 268   `apply_collapsed_panes`, a post-pass over `positions[..]` (one place, all three
 269   branches); a LEFT press on a collapsed stub expands it, and a right press on any
 270   stub (collapsed or detached) opens its plate menu. `a_plates_rows_are_in_its_right_click_menu`
 271   is the test.
 272 - `src/application.rs` — the `Application` impl: translates engine hooks into
 273   `WindowEvent`s, detached-window CSD, HTTP-server startup, exit autosave.
 274 - `src/window.rs` — `WindowEvent` plus the post-event side-effect pass
 275   (`process_window_event`: menu clicks, pane toggles) and HTTP-action application
 276   (`apply_custom_event`).
 277 - `src/render.rs` — `State::collect_display_list`: the frame's 2D content as one
 278   `cce_ui::scene::paint::DisplayList` (prims + `Prim::Text`), hand-maintained draw
 279   order over the widget slots, circular-pane clipping via `PaintItem::clip_circle`,
 280   network fade via text alpha. Rebuilt every drawn frame; the engine tessellates,
 281   shapes, and draws it.
 282 - `src/geometry.rs` — node-graph evaluation. Every evaluator threads an
 283   `EvalSim` (current frame + `SimCache` + feedback stack) alongside the error
 284   slot. The `simnet` node type iterates: the chain between its `input` and
 285   `output` children is one simulation STEP; step 1 eats the simnet's own
 286   `Input` (like a subnet), each later step eats the previous state, which the
 287   `input` node reads off the feedback stack instead of jumping to the outer
 288   graph. Solves run up to the playbar frame and cache per node id on `State::
 289   sim_cache` (playing forward = one step per frame); the cache key hashes the
 290   simnet subtree + seed; an edit goes on from the frame in hand under the
 291   new key (since 2026-09-30 — see "An edit is in from the next frame"
 292   below), and backward scrubs resume
 293   from the nearest CHECKPOINT behind them (steps are not invertible; until
 294   2026-09-29 they restarted from the seed — see "Simulation checkpoints"
 295   below). The scene walk does NOT recurse
 296   into a simnet's children — that would draw one un-iterated pass of the chain
 297   on top of the solved result. Dived INTO a simnet, the output child's
 298   geometry flag draws the solved state, and every OTHER visible child draws
 299   itself as the current frame's LAST SUBSTEP saw it, with the feedback stack
 300   holding the state that substep consumed (`simnet_step_feedback`, read off
 301   the `SimSolve` the solve already keeps): `input` shows what the pass
 302   reads, a chain node shows the pass that landed on the displayed state —
 303   so the chain's last mover draws where the output draws — and a node not
 304   wired into the chain at all simply draws. Until 2026-09-28 the feedback
 305   was the frame's STARTING state, so with four substeps a chain node drew
 306   three substeps behind the output, which read as the interior lagging a
 307   frame. Until 2026-09-21 only the output flag drew, and a visible
 308   node inside a simnet was a node you could not see. At any
 309   displayed level `input`/`output` children draw their resolved geometry (top
 310   level of the walk only, so outer views don't draw subnet chains twice).
 311   Frame changes invalidate the scene only when the
 312   graph `contains_simnet`. `network_sphere_vertices_with_errors` walks the
 313   graph from output nodes; node failures are collected into one error slot
 314   (still named `ocl_error` from the days it held OpenCL's), not fatal.
 315 - `src/viewport_3d.rs` — app-owned `Viewport3D` widget (camera orbit/zoom, inertial
 316   scroll, `rt_mode` flag switching the pane to the `cce_ui::vk` compute path tracer).
 317   **The traced pane's backdrop is the Background Color** (since
 318   2026-10-02, `VkRenderer::set_rt_background`, linear RGB like the raster
 319   quad's): a camera ray that meets nothing shows it, where it showed the
 320   tracer's studio sky. Only the camera ray — a bounce that leaves the
 321   scene still meets the sky, the tracer's one light, so the scene is lit
 322   as before. `--thumbnail` takes the colour from the project's display
 323   block and keeps the sky for a save without one (the bundled projects).
 324 - `src/viewer_state.rs` — the **viewer-state framework**: interactive viewport
 325   tools, generalized out of the curve tool. A viewer state is a mode the
 326   viewport is in, bound to one node, in which the pointer edits that node
 327   instead of orbiting the camera. The framework owns everything that turned out
 328   to be the same for any such tool: projection of world positions to handles
 329   through `State::last_scene_mvp` + `last_scene_view_rect` (both LOGICAL px,
 330   the rect divided by scale where it is cached — the same path as the
 331   Point Numbers overlay), hit-testing against `cursor_x/y`, dragging by
 332   unprojecting the cursor at the grabbed handle's captured NDC depth, snapping,
 333   the HUD, per-gesture undo (`cce_ui::history::History` of handle snapshots on
 334   the tool, so it lives exactly as long as the state does), binding by node ID
 335   rather than slot so renames don't detach it and a vanished node drops the
 336   state lazily, and write-back through the SetParam resync sequence
 337   (`sync_nodes` + `rebuild_scene_geometry` + `sync_parameters_pane`).
 338   Input hooks live in `handle_event`: presses intercept in the MouseInput arm
 339   ahead of the viewport context menu (gated on `cursor_in_viewport() &&
 340   !in_network_pane`, so the network keeps its clicks on its nodes),
 341   motion at the top of CursorMoved, Escape ahead of connection-cancel.
 342 
 343   What differs per tool is the `HandleSource` trait: which node types it
 344   accepts, where the handles are, how to write them back, whether the pointer
 345   may add and remove them, and what to label them. `source_for` is the one map
 346   from node type to tool, so the node context menu's Edit Handles entry, the
 347   `edit_handles` command and any future entry point cannot disagree about what
 348   is editable — adding a source makes it appear in the menu without touching
 349   the menu.
 350 
 351   Four implementations ship. The first two are deliberately different in shape, because an
 352   abstraction with a single implementation has not been shown to be one:
 353   `src/curve_tool.rs` (an open-ended list of world positions in the `curve`
 354   node's Points parameter, extensible) and `src/soft_transform_tool.rs` (a
 355   FIXED pair where the second handle is `Centre + Translation` — a derived
 356   position that has to be converted both ways, which is exactly what the trait
 357   exists to contain). The soft transform's two handles read as a vector with a
 358   base and a tip, and dragging either end changes the offset between them; a
 359   rule like "keep the translation when the centre moves" would be right for the
 360   drag and would quietly discard half of every restored undo snapshot, since
 361   `write` is handed a full set of handles with no word about which moved.
 362 
 363   **Three hooks were added for handles that are not world positions of
 364   their own** (2026-09-29, for the image tools). `read` and `write` take a
 365   `HandleCtx` — the `PageFrame` of the page the node draws on, and what a
 366   world unit is — gathered by the framework ahead of the call, because
 367   `write` holds the node mutably and can look nothing up. `drag(handles,
 368   moved, to)` is what the whole set is after one handle moves: moving the
 369   one is the default, and a source whose handles hang off one another
 370   carries them there (a shape's corner goes with its middle), so `write`
 371   is still handed a whole set that means one thing whether it came from a
 372   drag or an undo snapshot — the objection the soft transform's pair
 373   raises against "keep the translation when the centre moves" does not
 374   arise, since the rule is applied to the handles and not inside `write`.
 375   `plane` is the plane the handles live in: a drag then follows the
 376   cursor's ray to it, where without one it goes to the camera-facing
 377   plane at the grab depth, which leaves a flat thing's plane as soon as
 378   the view is not square to it. Two more are for the overlay: `outline`,
 379   a closed loop drawn under the handles, and `cage`, whether the handles
 380   are joined in order. Handle labels draw on a dark tab, since a handle
 381   can stand over a white image.
 382 
 383   The HUD draws one line up from the viewport's bottom left (the row it had
 384   above the scale readout, which is gone) — not at the top, because the viewport is full-bleed and the pane plates float over
 385   its top edge, so a mode line there lands under the collapsed stubs. It exists
 386   because a viewer state changes what every click does and snapping silently
 387   changes what a drag does.
 388 - `src/context.rs` — where a node may stand: the object level and the
 389   geometry context, the placement rule Add Node, paste and MCP hold, and
 390   the format-5 migration. See "The root is the object level".
 391 - `src/param.rs` — node parameters: `ParamDef` (text and parsed value kept
 392   together, both private), `ParamKind`, `ParamValue`, `ParamSlot`. See
 393   "Parameter kinds and typed values".
 394 - `src/project.rs` — save/load. A project is a **directory containing `state.json`**
 395   (`Project { name, root: FsNode, view_state }`); `default_project.json` in the crate
 396   root is special-cased as a single file and doubles as the detached-window sync channel.
 397 - `src/shortcut.rs` — `Shortcut::parse("Ctrl+Shift+g")` and chord → COMMAND ID
 398   matching (see the command registry above; a chord names a row in
 399   `src/command.rs`, not an `Action`). `Shortcut`'s equality is hand-written
 400   rather than derived, so it agrees with `matches` about case.
 401 
 402 The `zcce_inspector_v1` integration (window-position tracking + widget-state
 403 streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
 404 is the introspection surface.
 405 
 406 ### The root is the object level; geometry goes in a Geometry node (since 2026-10-02)
 407 
 408 Houdini's `/obj` and its geometry objects. The root holds **Geometry**
 409 nodes (`nodes/geometry.json`, type `geometry`), cameras, the Environment
 410 node and pages; every
 411 operator — generators, modifiers, subnets, simnets, repeats, the subnet
 412 templates — stands inside a Geometry node, at any depth. Until this every
 413 node could stand anywhere and the root was one big geometry level.
 414 `src/context.rs` is the whole rule.
 415 
 416 - **Placement is by node type** (`context::placement`): Object (the
 417   `geometry` container, `camera` and `environment`, root only), Any (the page nodes, which
 418   are a 2D context of their own and stay where they always could, and
 419   `export`, which writes a page or a mesh), Geometry (everything else, so a
 420   new node type is a geometry operator without a line anywhere). A level's
 421   context is `context_at(path)`: the root is Object, every level under it
 422   Geometry — since a subnet is an operator, the root's only enterable nodes
 423   are Geometry nodes. No Geometry node inside a Geometry node.
 424 - **Held where a node arrives**: the Add Node list shows only what fits the
 425   level (`refresh_dialog_rows`), MCP's `add_node` refuses with the reason on
 426   the status line (`context::refusal`), and a paste that does not fit is
 427   refused WHOLE ("Not pasted: …") rather than pasted in part with its wires
 428   cut. NOT held by the evaluator: a hand-built tree with a sphere at the
 429   root still draws, which is what keeps the suite's fixtures meaning what
 430   they meant.
 431 - **A Geometry node draws like a subnet seen from outside**: the scene walk
 432   goes in and draws its children by their flags, so dived in or not it shows
 433   its displayed node. Evaluated directly (an export at the root, `--export
 434   --node`, the spreadsheet) it is its displayed child, as a Houdini object
 435   is its display SOP. Its own flag is exclusive with nothing: several
 436   objects show at once at the root, and showing one turns no other off.
 437 - **Cameras stand at the root and are seen from every level**
 438   (`State::camera_level`). Until this a camera was looked up on the
 439   CURRENT level — `camera_names`, the pose, Frame All, the orbit and pan
 440   write-back, the rename — so diving into a subnet silently dropped to the
 441   Default Camera view; with all geometry one level down that would have
 442   been every working view. `frame_all_frames_the_root_camera_from_inside_a_subnet`.
 443 - **Format 5 migrates an older save** (`context::wrap_root_geometry`, in
 444   `Project::migrate_format`, so on every load path): every root child that
 445   is an operator goes, in order, with its position, flags and wires, into
 446   one new `geometry1` at the root on a free cell, shown. Cameras, pages, the
 447   retired `meta` / `session` / `utility` nodes (whose own migration runs
 448   after and finds them there) and an export reading a root page stay. A
 449   wire needs nothing — wires look among siblings first and the siblings came
 450   along — but a CHANNEL PATH does: one that reaches into the moved nodes
 451   absolutely (`/sphere1/radius` → `/geometry1/sphere1/radius`), or crosses
 452   between them and the root relatively (`../camera1/pivot.x` from a moved
 453   node → `../../camera1/pivot.x`), is resolved in the old tree and written
 454   again from where its holder now stands; paths that do not cross are left
 455   as written. The view follows: an editor at the root opens inside the new
 456   node on the node it had selected (unless that was a camera or a page), and
 457   a path into a moved subnet goes through it. The meta migration re-homes a
 458   subnet it finds in the root's first Geometry node (`context::geometry_home`).
 459   The bundled `default_project.json` and `project.json` are NOT rewritten on
 460   disk: they carry no format and migrate on every load, so the suite's
 461   `State::new` opens inside `geometry1` (`test_prelude::geo` reaches it).
 462   Checked on the user's project and both bundled ones: the old build and the
 463   new export the same mesh at frames 1, 30 and 120.
 464 
 465 `an_older_save_puts_its_geometry_in_a_geometry_node`,
 466 `geometry_nodes_at_the_root_each_show_their_own` and
 467 `the_add_node_list_offers_what_belongs_at_the_level` are the tests.
 468 
 469 ### The Environment node: one light for both views (since 2026-10-02)
 470 
 471 `src/environment.rs` and `nodes/environment.json`. The raster pass and the
 472 path tracer each had a light of their own, hard-coded and pointing
 473 different ways — the raster one, read the right way round, from BELOW
 474 (`(-0.55, 0.45, 0.7)` dotted with the shader's inward normal), the
 475 tracer's sun at `(0.45, 0.75, 0.35)` — so switching modes moved the lit
 476 side of a model. Now one `Environment` lights both: its sun direction goes
 477 to the flat shader (`VkRenderer::set_scene_light`, cce-ui) and the smooth
 478 bake (`shade_factor(n, toward)`), and all of it to the tracer's sky
 479 (`VkRenderer::set_rt_environment` / `RtOffscreen::set_environment`, an
 480 `RtEnvironment`: sun direction, sun radiance, zenith and nadir colours).
 481 
 482 - **The node stands at the root** (an Object placement). The scene's
 483   environment is the root's first `environment` node that is NOT BYPASSED;
 484   with none it is `Environment::default()`, which is the template's
 485   defaults (`the_environment_node_lights_both_views` holds the two equal) —
 486   so adding one changes nothing until a row moves, and Bypass is how to
 487   compare. Not the display flag: a node arrives with it off, and an
 488   environment that did nothing until `e` would read as broken.
 489 - **Rows**: Sun Azimuth (degrees about +Y, 0 toward +Z, 90 toward +X),
 490   Sun Elevation (above the horizon; below 0 lights from underneath), Sun
 491   Intensity and Sun Color, Sky Color (overhead), Ground Color (straight
 492   down) and Sky Intensity. The defaults are the tracer's old sky to the
 493   nearest degree, so the traced view looks as it did; the raster view now
 494   lights from that sun, from above, where it was lit from below.
 495 - **What each view takes**: the raster pass the DIRECTION only — its
 496   shading is a 0.55..1 wrap of the surface colour, not a light with a
 497   strength; the tracer everything. Colours are LINEAR, as the tracer reads
 498   them, and an intensity may push them past 1. The sky stays the tracer's
 499   only light, and with the Background Color behind the scene (above) it is
 500   seen only in what it lights.
 501 - **Rows evaluate at the current frame** (`Environment::of_scene`, through
 502   `resolve_param_refs`), so a sun can move with `$F`. `State::environment`
 503   is the value in use: `present_scene` reads it for the bake, and
 504   `sync_environment` runs from the tick, re-baking a smooth fill from
 505   `scene_base` (no evaluation) when it moved, so an animated sun or an edit
 506   that rebuilt nothing is still seen. `--thumbnail` reads it from the
 507   project at the frame it renders.
 508 
 509 Verified in a shadow session against a grey sphere: with the sun at
 510 azimuth 90 both views are brighter on the right, at 270 both on the left.
 511 
 512 ### There are no meta nodes (retired 2026-09-23)
 513 
 514 Two different things were called `meta`, and both are gone. What replaced
 515 them is the one rule worth remembering: **a display setting is a live
 516 field on `State`, persisted to `state.kdl`, and reached from the command
 517 palette.** Never a node. (Since 2026-09-24 a project ALSO carries the
 518 display settings it was saved with — see "Display settings ride the
 519 project file" below; that is a snapshot in the view state, not a node.)
 520 
 521 **The root `meta` node (nee Session)** was a permanent, undeletable root
 522 subnet holding four utility subnets — `main`, `view`, `guides`, `render` —
 523 whose params were every session-wide setting. It was the STORE OF RECORD:
 524 `ensure_menubar_subnets` rebuilt it from live state and
 525 `apply_settings_from_menubar_subnets` copied it back OVER live state after
 526 every parameter edit anywhere. Three things followed, all bad. A display
 527 preference was project data, carried in the file and reset by opening
 528 someone else's scene. Half of those settings were reachable only by finding
 529 the right node in the right subnet. And a command that flipped a live flag
 530 was undone by the next unrelated edit unless it also wrote the node — which
 531 is what `write_guides_toggle` / `write_render_toggle` existed for, and what
 532 made "Show Cube hides the cube until you touch any parameter" a real bug.
 533 
 534 **The per-node `meta` child** was a hidden child on every geometry node
 535 carrying four display switches (Point Markers, Point Numbers, Point Normals,
 536 Wireframe), so seeing the point numbering of what was on screen meant diving
 537 into each node and flipping its own switch, one node at a time. Wireframe
 538 was already duplicated by a global `toggle_wireframe`.
 539 
 540 Where it all went:
 541 
 542 - **Display settings** are live `State` fields, persisted by
 543   `DesignSettings` into `state.kdl` (`viewport` and the new `render` block),
 544   and edited as rows of the dialog's one list — `SETTINGS` in
 545   `src/dialog.rs`, whose rows are `Owner::Field` (a live field, with a `Ctl`
 546   saying what control draws it); the toggles are
 547   registry commands whose palette rows carry a switch, read through
 548   `command_toggle_state`. The table plus the toggle commands are the app's
 549   whole display configuration, so a value left out of both is GONE, not
 550   merely hidden — `every_retired_subnet_setting_is_reachable` is the
 551   backstop, and `dialog_settings_rows_name_owners_that_exist` round-trips
 552   every `Field` row because a key no dispatch arm names draws, accepts an
 553   edit and does nothing.
 554 - **The three point overlays** are `toggle_point_markers` / `_numbers` /
 555   `_normals`, collected in `rebuild_scene_geometry` off the merged scene
 556   `Detail` (`render::scene_point_overlays`) rather than by a second walk that
 557   re-evaluated every flagged node. **Wireframe folded into the existing
 558   `toggle_wireframe`**, and the survivor draws the TOPOLOGICAL edge list
 559   (`render::scene_edge_verts`) the per-node flag used, not the triangle soup
 560   the global one did — shared edges once, quads as quads.
 561 - **Main's buttons** (New/Open/Save/Save As/Set As Default/Exit, Undo/Redo,
 562   the zoom family, Detach Circular Window) were already registry commands.
 563   Three settings that were toggles on those nodes and reachable NOWHERE else
 564   became commands: `toggle_ray_traced_preview`, `toggle_wire_single_color`,
 565   and `toggle_render_points` — Show Points, which was retired on
 566   2026-09-29 (see "Display mode" below).
 567 - **The recent-projects list** was the Main node's "Open" dropdown, which
 568   would have left `recent_files` written and read by nothing. It is rows at
 569   the head of the palette's Commands list (`RECENT_ROW_PREFIX`), under the
 570   open project's own path row; picking one opens it.
 571 - **Pane visibility** was the `view` subnet's five toggles riding `fs_root`
 572   into the file. It is genuinely project state, so it moved to
 573   `ProjectViewState::visible_panes` beside the collapse list and the
 574   splitters. `State::PANE_FLAGS` is the one table the save and the load share.
 575 - **The active camera** keeps the viewport menubar's own menu, whose entries
 576   are the camera NODES — not something a fixed table can hold.
 577 
 578 `Project::migrate_meta_settings_node` runs on every load: it takes the meta
 579 node (and the four subnets, which PRE-Session saves parked flat at the root —
 580 hence no early return on the container alone), reads its values onto the live
 581 state, and saves them to `state.kdl`. Per-node children go in
 582 `app::strip_meta_children`, called from `merge_template_defs` because that is
 583 the one function every deserialization runs. Their VALUES are dropped
 584 deliberately: four per-node booleans do not reduce to one global switch, and
 585 inferring one would turn a single node's preference into a setting over the
 586 whole scene.
 587 
 588 Gone with them: `session_node()`, `in_settings_dir()` (there is no settings
 589 directory; what Add Node offers is now the level's CONTEXT — see "The root
 590 is the object level"), `write_meta_toggle`
 591 and its two wrappers, `refresh_main_node_live_toggles`,
 592 `update_recent_files_layout`, the `utility` / `session` / `meta` node types,
 593 the undeletable-node gate in `delete_node`, and `layout.rs`'s pinning (whose
 594 only pinned nodes were these).
 595 
 596 **"World Unit"** (mm / cm / m / in, `State::world_unit`) survives as a
 597 Settings row — what one world unit IS. Geometry never converts; the
 598 declaration feeds the viewport context menu's **View 1:1**
 599 (`view_one_to_one`) through the display metric (`cce_ui::units`), which moves
 600 the active camera along its eye ray so the pivot plane shows one world unit
 601 at its true length — the default camera by zoom, a camera node by rewriting
 602 its Position, as Frame All does. The projection is a perspective (vertical
 603 FOV 0.9 rad), so 1:1 holds on the pivot plane only; `view_scale_ratio` is the
 604 view's scale there. The viewport's bottom-left **scale readout** (`1:2.3 ·
 605 1 mm = 0.43 mm on screen`) that showed it was removed on 2026-10-06.
 606 
 607 ### App-written settings: `~/.config/cce/cce-designer/state.kdl`
 608 
 609 `default_project` in state.kdl points at the project the main window opens on
 610 startup (the `set_as_default` command; absent = the bundled
 611 `default_project.json`). It is a POINTER, never a rewrite of
 612 default_project.json — that file is versioned and is the detached-window sync
 613 channel. Detached windows ignore it: they must keep seeding from the sync
 614 channel.
 615 
 616 **A default that cannot be opened is not forgotten.** The launch falls back to
 617 the bundled project and says so on the status line, keeping the pointer. Until
 618 2026-09-23 a path that did not exist was DELETED from the settings, reasoning
 619 that a dead default should not fail on every launch — the trade is the wrong
 620 way round. Failing costs one line of stderr and a fallback that already works;
 621 forgetting costs a setting the user can only restore by reopening the project
 622 and pressing the button again. And a path is absent for reasons that pass — a
 623 cloud-synced folder the daemon has not mounted yet, an external drive, an
 624 autostart that beat the network — so the one launch that raced the filesystem
 625 took the setting with it, silently. (Found exactly that way: a default under
 626 `~/Dropbox` that stopped opening, with the key simply gone from state.kdl.)
 627 
 628 **`gpu`** in state.kdl (`integrated` | `discrete`, the dialog's **GPU**
 629 row) picks the device the window's renderer asks Vulkan for. It is read ONCE,
 630 by `app::apply_gpu_preference` in `main` just before `engine::run`, and set
 631 as `CCE_VK_DEVICE` — the variable cce-ui's device selection already reads,
 632 which also steers `gpu.rs`'s compute device — so a change takes effect on the
 633 next launch, and the row's status line says whether the running process is
 634 on it (`gpu_at_launch`). Three rules, all in `gpu_env_for`: an explicit
 635 `CCE_VK_DEVICE` in the environment wins (a per-run override); `integrated`
 636 sets NOTHING, because cce-ui treats any explicit request as licence to lift
 637 the session's `VK_DRIVER_FILES` pin to the Intel ICD, which loads the NVIDIA
 638 driver and wakes the dGPU just to enumerate it; and the thumbnail and export
 639 modes exit before the call, so cce-files' preview cache never wakes it
 640 either. Top-level in `DesignSettings`, beside `default_project`, not in the
 641 render block — that block rides the project file, and which GPU a machine
 642 has is not a property of a scene. Verified under the session's pin: unset
 643 opens the Iris Xe, `discrete` the RTX 4080.
 644 
 645 **`playbar_repeat`** (top-level too, since 2026-09-28) is whether playback
 646 wraps at the end of the frame range or stops on the last frame — the
 647 `toggle_playbar_repeat` command, a switch in the palette, unbound. The one
 648 copy is `Playbar::repeat`; with it off a play press on a timeline stopped
 649 at its far end restarts from the near one (`Playbar::begin`, which the
 650 button and the Up/Down chords share).
 651 
 652 `DesignSettings` (viewport/graph display state the app rewrites itself:
 653 colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
 654 `config.kdl`, which is the user-authored toolkit-config override slot that
 655 cce-ui auto-merges (see `../cce-compositor/WORKSPACE.md`). Legacy `design.kdl` / `design.json`
 656 files migrate on load. Scroll behavior (`scroll_speed`, `inertial_scroll`,
 657 `scroll_friction`) is intentionally absent: it is config-owned
 658 (`input.inertial` in config.kdl) and must not be shadowed by app state.
 659 
 660 **Display settings ride the project file too (since 2026-09-24).** Every
 661 save writes `ProjectViewState::display` — a `DisplaySettings`, the viewport
 662 and render blocks of `DesignSettings` without the startup pointer, taken by
 663 `State::display_settings` (which `save_settings` builds from as well) — and
 664 both `load_from_file` paths apply it through `apply_display_settings`, before
 665 the Default Camera view so a camera node's own Pivot still wins. The apply sets every field, regenerates the baked meshes, relays
 666 the pane-shaped one (the circular pane), re-checks the
 667 menubar marks, and saves state.kdl, so state.kdl holds the LAST-USED look:
 668 what New and an older save (no block, which changes nothing) open with.
 669 Main window only, as the pane state is: a detached window has no viewport
 670 and reloads the sync channel on every write. A display change dirties the
 671 project (`pane_layout_json` includes the block). `State::new` seeds only
 672 the tree, camera, pan and path from the bundled file, as before, so the
 673 suite does not read the block out of the versioned `default_project.json`.
 674 This reverses the 2026-09-23 position that a display preference should
 675 survive opening someone else's scene — the user's call;
 676 `a_project_keeps_its_display_settings` is the test.
 677 
 678 **The path honors `$XDG_CONFIG_HOME`**, resolved through
 679 `cce_ui::config::cce_config_dir()` like every other app in the workspace —
 680 this one hardcoded `$HOME/.config` until 2026-09-23 and was the only holdout.
 681 
 682 **And under `cfg(test)` it is a temp directory**, which is the part worth
 683 knowing. `State::new` loads the bundled project, whose meta subnets used to
 684 be copied over the live viewport flags after every parameter change (the
 685 meta node is retired, but the hazard was real and this redirect is what
 686 caught it); so any test that then reached `save_settings` —
 687 `run_command("toggle_network_plate")` (retired since), the dialog's toggle rows — wrote the
 688 BUNDLED project's show_grid / show_cube / show_origin over the user's real
 689 state.kdl. `cargo test` reset three of the user's own toggles on every run,
 690 and the run was green either way. `Project::load_recent_files` /
 691 `save_recent_files` are gated the same way, for a variant of the same reason:
 692 cce-ui derives that path from the EXE's basename, so test binaries had left
 693 seven real `~/.config/cce/cce_designer-<hash>/` directories behind. So is the
 694 simnet disk cache (`geometry::sim_cache_path`, since 2026-09-28): a test that
 695 solves a Cache-on simnet writes under a process-scoped temp directory, never
 696 `~/.cache/cce/cce-designer/sim`. That file carries the solve's `prev` beside
 697 its state, so a resume landing exactly on the asked frame draws the interior
 698 view from what the last substep consumed rather than the seed; a file in the
 699 older shape is refused and rewritten.
 700 
 701 The redirect is in `DesignSettings::file_path` itself rather than in an
 702 environment variable the test module sets, because a variable leaves the
 703 guarantee resting on every future test remembering to set it BEFORE touching
 704 `State` — and the test that forgets destroys real settings, leaving nothing
 705 behind but toggles that came back wrong. `the_suite_does_not_write_the_users_own_settings`
 706 is the backstop: it spells the real path out itself (`file_path()` being the
 707 thing under test), runs the plate toggle, and asserts both that a settings
 708 file was actually written — or the check is vacuous — and that the real one
 709 did not move.
 710 
 711 **The suite's LATTICE is pinned for the same reason**, one layer up:
 712 `configured_grid_geometry` read `style.surface.graph.spacing_x` and friends
 713 straight out of `~/.config/cce/config.kdl`, and the grid tests press at pixel
 714 coordinates derived from `cell_center` and assert which node the press landed
 715 on — so the pitch on the machine decided whether they passed.
 716 `dragging_a_selected_node_carries_the_selection` really did fail at cce-ui's
 717 own defaults (187.5 x 112.5 puts its row 11 at 1237 px in a 900 px test
 718 window, so the press misses the node and the drag never arms); it passed only
 719 because the author's config.kdl set 140 x 70. A fresh clone, a second machine
 720 or CI would all have failed it, reading as a broken drag rather than a
 721 borrowed lattice.
 722 
 723 Under `cfg(test)` the four values are fixed at those 140 / 70 / 80 / 40 — the
 724 lattice the grid tests were written against, so pinning them changed no test's
 725 meaning. Deliberately NOT cce-ui's defaults: matching those would mean
 726 rewriting the cell arithmetic of a subtle drag test to fit a coarser grid,
 727 a real change to what it checks for the sake of a number that is arbitrary
 728 either way. What matters is that the number is the suite's own.
 729 `the_suite_runs_on_a_lattice_of_its_own` asserts the constants back — not a
 730 tautology but the thing that fails if the pin is ever unwired to the config
 731 again — and checks the live `State` alongside them, so the pin has to reach
 732 the app and not just the helper. Verified by running the suite under an EMPTY
 733 `$XDG_CONFIG_HOME`, under one setting 999 x 777 with 500 x 400 nodes, and
 734 under the real config: 305 passing, identically, all three.
 735 
 736 `graph_grid_snap` is not pinned — it is read inside cce-ui's Graph widget
 737 rather than through this crate, so there is nothing here to intercept; it is
 738 off both by cce-ui default and in practice. Config the suite still reads is
 739 cosmetic in the same way (colors, fonts, plate radii), and no test asserts on
 740 it; the empty-`$XDG_CONFIG_HOME` run is how to check that claim again.
 741 
 742 ### OpenCL is retired (2026-09-24)
 743 
 744 There is no OpenCL in this crate any more: no `opencl` node, no
 745 `kernel_cpu.rs`, no launcher, no `opencl3` dependency, no
 746 `CCE_KERNEL_CPU`. Phase 7 of `shapeshifter.md` is where the decision is
 747 argued; the short form is that the only scripting surface was a C subset
 748 carried by two backends that had to agree, every shipped kernel was serial
 749 (`if (id == 0)`), and the four templates that used them are native nodes
 750 now. Per-element scripting is the `wrangle` node (Rhai, CPU). GPU
 751 parallelism, when a solver needs it, comes back as WGSL compute through
 752 cce-ui's renderer — step 4 of the same phase — not as OpenCL.
 753 
 754 **An `opencl` node in an old save is not dropped.** `retired_opencl_node`
 755 passes its input through and reports `<name>: OpenCL nodes are retired;
 756 rewrite the kernel as a wrangle` through the error slot, so the status line
 757 says what happened and the fix is one rewrite. The type stays in
 758 `is_geometry_node_type` for exactly that arm.
 759 
 760 **What left with it, for the record.** Mesa's Rusticl ICD closed a file
 761 descriptor it did not own under `clGetPlatformIDs` (caught under `strace
 762 -k` on 2026-09-23), which had the suite failing one run in eight on
 763 whichever template file lost the race, and made `CCE_KERNEL_CPU=1` the only
 764 reliable way to run it. Probing less often did not help; forcing the CPU
 765 backend did not help until it also stopped loading the ICD; what worked was
 766 not loading it. The retirement is the final form of that fix. The
 767 diagnosis is in the git history of this section (commit `8fd0c29`) if the
 768 pattern ever recurs with another driver: a `read` returning EBADF on a file
 769 nothing is wrong with, in a process that has loaded a vendor ICD.
 770 
 771 ### A parameter has a name and a label (since 2026-10-01)
 772 
 773 A parameter's **name** is an identifier — lowercase letters, digits and
 774 underscores (`param::is_param_name`): `base_resolution`, `input_2`,
 775 `attribute_name`. It is what a `ch()` path spells (`../sphere1/radius`),
 776 what `show_when` conditions name (`operation == Create`), what MCP and
 777 the code use, and it is NEVER shown in the params pane. The pane shows
 778 the **label** (`Base Resolution`; the Attribute node's `attribute_name`
 779 is labelled just `Name`), which may say anything. The label is the
 780 template's UI metadata like the description: `adopt_ui_from` hands it to
 781 every instance. Node names follow the same convention, so a path reads
 782 as one thing.
 783 
 784 - **Every template parameter carries both**, and a template whose
 785   parameter name is not one is refused at load (`misnamed_params`, beside
 786   the unknown-kind refusal). Until this, no template set a label: the
 787   name WAS the pane's text, Title Case with spaces, and every `ch()` path
 788   had to spell it that way.
 789 - **`ParamDef::shown_name`** is the one reading of what the pane shows —
 790   the label, or the name where there is none (a parameter added by hand
 791   or over MCP). The pane's rows are keyed by it, so the write-back,
 792   `param_row_at` and the expression tint resolve a row through it;
 793   `param_row_at` hands back the NAME. Messages a person reads beside the
 794   pane say the label (the refused-value status line, the load's invalid
 795   values); an expression's error says the name it was written with.
 796 - **Format 4** (`Project::migrate_param_names`) renames a save's
 797   parameters by `param_name_of` (`Relax in 3D Space` →
 798   `relax_in_3d_space`) and every channel path in an expression and in a
 799   wrangle's Code with them — the last segment, less a `.x` component.
 800   `show_when` is left alone (the merge replaces it, and
 801   `page::migrate_preset_rows` recognises an old page by its condition),
 802   and so are the retired `meta` / `session` / `utility` nodes, which the
 803   meta migration reads by their saved names after it. Format-1–3 steps
 804   run before it and so still spell the old names (`rename_attribute`'s
 805   `Attributes`). Checked on the user's project and both bundled ones:
 806   the old build and the new export the same mesh at frames 1, 30 and 120.
 807 - **MCP**: `set_param` / `delete_param` take the name, else the label
 808   (any case), else `param_name_of` of what was given — so a script
 809   written against `Base Resolution` still lands
 810   (`param_by_name_or_label`). `add_param` refuses a name that is not one,
 811   suggesting the form, and takes an optional `label`.
 812 - **Under test, `find_param` asserts the name it is asked for is one**,
 813   and matches exactly (it was case-insensitive). A reader still spelling
 814   a label finds nothing and reads its fallback — a node quietly deaf to
 815   a row — so the suite fails it loudly instead. `param_visible` matches
 816   names exactly too.
 817 
 818 `parameters_have_a_name_and_a_label` is the test.
 819 
 820 ### Parameter kinds and typed values
 821 
 822 `src/param.rs` owns `ParamDef`, `ParamKind`, `ParamValue` and `ParamSlot`
 823 (re-exported from `app`). A `type` string names a `ParamKind` —
 824 `ParamKind::parse` reads the head before the first `:` (`slider:-2:2` is a
 825 Slider, `choice:A,B` a Choice; `string`, what an absent type deserializes to,
 826 is Text) — and `ParamDef::kind()` is the one place the string is interpreted.
 827 
 828 **A parameter keeps its TEXT and what that text parses to, together.** Both
 829 fields are private; `text()` is the value as written (`"0.50"` stays
 830 `"0.50"`), `slot()` is `Value(ParamValue)`, `Expr` or `Invalid(why)`, and
 831 every setter re-parses, so the two cannot disagree. `set_text` is the one
 832 way a value changes (it keeps the expression flag), `set_value` writes a
 833 typed value, `bake` writes a value and clears the flag (an evaluated
 834 expression, Delete Expression), `set_expr` flips the flag, `set_type` /
 835 `adopt_ui_from` change the kind and re-parse — the template merge goes
 836 through `adopt_ui_from`, so an old save's text wire is a node wire and an
 837 old text Center a float3 from the load on. Tests build parameters with
 838 `ParamDef::new(name, type, text)` and the `with_*` builders.
 839 
 840 - **The file format did not move.** Serde goes through `ParamDefRepr`, the
 841   old struct field for field and in order, so a re-save is byte-identical
 842   and `sim_solve_key` — a hash of the simnet's JSON — does not restart
 843   cached simulations. `params_serialize_as_they_always_did` walks every
 844   template and both bundled projects; the user's own project was checked
 845   the same way when this landed (128 parameters, identical).
 846 - **A text that does not fit is kept, never coerced.** It loads as
 847   `Invalid`, readers fall back exactly as they did when every read parsed
 848   the string (`node_param_f32` on `4.5` in a spinbox is still 4.5), and a
 849   load says so on the status line (`report_invalid_params`, only over the
 850   plain success message). What REFUSES one is the two places a person types:
 851   the params pane (`sync_parameters_to_project`: nothing written, the row
 852   shows the kept text again, "Not applied — Threshold: 'abc' is not a
 853   number") and MCP's `set_param` / `add_param`. A value that reads as an
 854   expression goes through as one and is checked when it evaluates.
 855 - **Readers take the parsed value** (`node_param_f32` / `_vec3` / `_bool`,
 856   `param_number`) and fall back to the text for a slot that is not the
 857   kind they want — so a text row holding `12` still reads 12.
 858 - **Expressions evaluate to a typed value.** `eval_param_value` returns
 859   `Evaluated`: `value_from_expr` converts by the row (a number into a toggle
 860   is its truth, into a choice the option at that index, into a spinbox its
 861   whole part), and a result that fits nothing is stored as its text and
 862   flagged, which is what the old string write-back did.
 863   `resolve_param_refs` stores either with `set_value` / `bake`.
 864 
 865 - **A type naming no kind is refused**, not read as text: `load_fs_tree`
 866   drops the template with a message (as it does an unparseable one), MCP's
 867   `add_param` returns an error listing `ParamKind::NAMES`, and
 868   `every_shipped_template_param_has_a_known_kind` walks the raw files.
 869   `kind()` itself still falls back to Text so a hand-edited save stays
 870   editable.
 871 - **`node` is a wire**: every `Input`, Switch's `Input 2`–`4`, Boolean's
 872   `With`, Collision's `Collider`, Relax's `Rest`, Suture's `Against`, Copy's
 873   and Distance's `To`, Transfer's `From`. Read them with `node_param_node`
 874   (trimmed, `None` when unconnected) or resolve them with `param_node`,
 875   never by hand. By template, not by name: the Attribute node's `From` /
 876   `To` are ranges.
 877 - **`attribute` and `group` name a point attribute or a point group on the
 878   node's input** (since 2026-09-28) — read or written, it is the same kind:
 879   Visualize's `Attribute` and Normal's are both `attribute`, Relax's `Pin
 880   Group` and the Group node's `Group Name` both `group`. Any text is a valid
 881   value; what the kind changes is the params pane, where `add_pick_lists`
 882   upgrades every row of either kind to a `textpick` (the Houdini chooser,
 883   a text box with a picker of the input's names) when the input has any to
 884   offer. Until then the pane knew FOUR rows by (node type, row name) —
 885   Attribute's `Attribute Name` and `Group`, Group's `Group Name`, Relax's
 886   `Pin Group` — and the other thirty-odd rows that name one were text boxes
 887   typed into blind; a template declares it now and needs no entry anywhere.
 888   What is still `text` is text for a reason: Attribute's `Value` is as wide
 889   as its `Type` row says (one number or two, three or four) — though the
 890   pane PRESENTS it as a control as wide as its target (`add_pick_lists` /
 891   `value_row_control`): a slider for one, cce-ui's `float2` / `float3` /
 892   `float4` group for more (the float3 since 2026-09-28, every width since
 893   2026-10-01). **Its range adapts to the value** (`value_row_span`, the
 894   same day; it was a fixed ±1000, which a drag crossed in hundreds): ± the
 895   smallest power of ten, at least one, whose middle half holds every
 896   component — 1.00 drags over ±10, 9.6 over ±100. The span in use
 897   (`State::value_row_span`, by node) is KEPT while the value stays between
 898   a twentieth and nineteen twentieths of it, and is never re-chosen during
 899   a drag in the pane (`drag_widget == PARAM_IDX`): a new span is a new
 900   row type, which rebuilds the pane and would drop the slider being held.
 901   So a drag to the end re-scales on the release. The rows are cce-ui
 902   SOFT ranges (`:soft`): a value typed past an end widens the range
 903   rather than clamping, and the next span holds it. The target
 904   is Create's Type, or Modify's Pos, Col or input attribute. The text must
 905   hold one number or as many as the target: ONE is shown spread over every
 906   component, as the node spreads it (`fit` in `apply_attribute`), and the
 907   pane's write-back reading that spread back unchanged is not an edit
 908   (`same_value_row_text`), or every sync would rewrite `1.00` as
 909   `1.00:1.00:1.00` and file an undo step. An expression, a text that fits
 910   no width and an attribute the input lacks keep the text box; the
 911   parameter, the file and MCP see text throughout. **Value From** (Constant
 912   / Attribute, since the same day, Create and Modify) replaces the Value
 913   row with **From Attribute**, an `attribute` row: each point's own value
 914   of it is used in place of the constant — copied when as wide, a single
 915   number spread, otherwise as many components as fit and the rest zero;
 916   Pos and Col name the position and the colour; read before anything is
 917   written, so a Modify can read the attribute it writes. A save from
 918   before has Constant. `test_attribute_takes_its_value_from_another_attribute`
 919   and `an_attribute_value_row_is_a_control_as_wide_as_its_target` are the
 920   tests — Transfer's
 921   `Attributes` is a comma LIST of names, Simnet's `Start Frame` is empty for
 922   "the playbar's", Bounds' `Prefix` is a prefix, Curve's `Points` a list of
 923   positions, Export's `File` a path. The same day Attribute's `From Min` /
 924   `From Max` / `To Min` / `To Max` became `float` and Neighbour's `Constant`
 925   a `float3`, the numbers phase 1 missed (the four are one `float2` pair
 926   since 2026-10-06, below).
 927   `template_params_carry_the_kind_they_hold` pins all of it, including the
 928   rows that stay text.
 929 - **A float3 row can carry a trackball** (since 2026-09-28): cce-ui's
 930   ball beside the three sliders, which turns the vector's DIRECTION and
 931   keeps its length, where the sliders set a component at a time. The
 932   display type is `float3:lo:hi:trackball` (`float3_row`). It is on by
 933   default where the three numbers are a vector — the Attribute node's
 934   Value aimed at Pos or a Float3 attribute, the pull node's — and off for
 935   Col (a colour points nowhere) and for every `float3` parameter (a
 936   Center, a Size); the row menu's **Show Trackball** / **Hide Trackball**
 937   chooses either way. The choice is `ParamDef::view` (`trackball`,
 938   `sliders`, empty for the default), the one piece of UI metadata the
 939   INSTANCE owns: `adopt_ui_from` fills it from the template only where the
 940   instance has not chosen, and it is serialized only when set, so a file
 941   that never used it is byte-identical. The row menu reads such a row as
 942   `Control: trackball and sliders`, `Type: float3`. With the ball on the
 943   row writes three decimals. **The ball is seen from the viewport's
 944   camera** (`State::sync_trackball_view`, called from the stage pass once
 945   the active camera's pose is resolved): the view matrix's rotation goes
 946   to the pane as the camera's right, up and toward axes, so the vector on
 947   the ball lies as the pull arrows do in the viewport beside it, and
 948   rolling the ball right swings the vector to the right of the SCREEN. The
 949   numbers stay the scene's. The pane may already be painted when the
 950   stage pass runs, so a moved view returns true from `stage_frame` for one
 951   more frame: the ball trails an orbit by a frame, never by more. **A
 952   detached parameters window follows the camera too**, over the sync
 953   channel: the main window asks for an autosave when its trackball view
 954   moves (`sync_trackball_view_from_camera`), the file carries the camera —
 955   the active camera's name and node, the Default Camera's orbit in
 956   `default_view` — and the detached window, which has no 3D canvas, works
 957   the view out from what it reloaded (`active_camera_pose`, split out of
 958   the stage pass for exactly this). It trails by the autosave's debounce
 959   and the poll, a few tenths of a second.
 960 - **`float` is a number with no range.** The pane's slider and float3 rows
 961   hold a FRACTION of their range and clamp to it, so a threshold, a scale
 962   factor or a manual ramp end cannot be a slider without losing values
 963   outside it. `param_display` shows `float` and `node` as text rows — cce-ui
 964   is shared and has neither, and the conversion stays at this app's edge.
 965 - **`float2` is a range's two ends, `lo:hi`** (since 2026-10-06): the
 966   Attribute node's From and To (Remap; Clip's bounds are From) and
 967   Visualize's Manual Range, which were two `float` rows each. Shown as
 968   cce-ui's two-slider `float2` group over a SOFT span around the value —
 969   the Value row's rule (`value_row_span`): ± the smallest power of ten
 970   whose middle half holds both ends, kept while the value stays in it and
 971   through a drag in the pane (`State::present_float2_rows`, per
 972   parameter, `State::float2_spans`), and widened rather than clamped by a
 973   value typed past an end. So it holds what a `float` held. It has no
 974   `range()`: the pane chooses the span. Read with `node_param_vec2`. Each
 975   component may be an expression, as a float3's are, and `.x` / `.y` read
 976   one. A text that is not two numbers shows as a text box, to be put
 977   right. **Remap's From can come from the input** (2026-10-06): the
 978   **From Range** row (`from_range`, Manual / Auto, Remap only) set to Auto
 979   measures the lowest and highest value of the Name over the Group — every
 980   component counted, since From is one range applied to every component
 981   (`geometry::component_range`) — at every evaluation, so the range follows
 982   a simulation's values; From is hidden then, and a flat input maps to To's
 983   first end rather than failing. Under Manual a **Detect Range** button
 984   (`detect_range`) takes the same measure once (`remap_input_range`, off the
 985   input as the scene shows it at the current frame) and writes it into From
 986   as an undoable edit. A node from before has no row and is Manual.
 987   `a_remap_can_take_its_from_range_from_the_input` and
 988   `the_remap_from_range_is_detected_from_the_input` are the tests.
 989   Normalize's goal was To Max and is a row of its own now,
 990   **Normalize To** (`float`). Format 6 joins an older save's pairs (see
 991   "Parameter expressions"). `a_range_is_one_float2_row` is the test.
 992 - **Toggles read through `node_param_bool`**: `true`/`1`/`on` and
 993   `false`/`0`/`off` in any case, else the fallback. The sites it replaced
 994   mixed `== "true"` and `!= "false"`, which disagreed about garbage.
 995 - **Choices are still read as option TEXT** (`eq_ignore_ascii_case`), not
 996   by index. Matching text survives a template reordering its options; an
 997   index would silently change meaning. Expressions, which need a number,
 998   already get the index through `param_number`.
 999 
1000 Saved projects need no migration for any of this: `merge_template_defs`
1001 hands every instance its template's type along with the rest of the UI
1002 metadata (`adopt_ui_from`, which re-parses), so an old save's
1003 `"type": "text"` wire loads as `node` (`a_saved_text_wire_loads_as_a_node_wire`).
1004 
1005 ### The params pane separates groups of parameters (since 2026-10-01)
1006 
1007 A template parameter may carry `"group": "<name>"`; `param_display` puts
1008 a separator row (cce-ui's `separator`, a hairline) wherever two rows it
1009 SHOWS belong to different groups (`param_group`). So a separator is only
1010 ever between two shown rows — never first, last or doubled — and a run
1011 whose rows are all hidden by `show_when` leaves no line behind. The
1012 LEADING run of wires (the node's inputs, at the top of its list) is a
1013 group of its own without a template saying so (`PARAM_INPUTS_GROUP`), so
1014 every node with an input has a line under it; a wire further down
1015 (Relax's Rest) is part of whatever run it is in. Rows with no group are
1016 the node's unnamed run, so a template names only the runs after the first.
1017 
1018 - **The group is the template's**, as the description is: `adopt_ui_from`
1019   hands it to every instance, and it is read and never written, so a save
1020   is byte-identical and `sim_solve_key` does not move.
1021 - **Groups follow the template's ORDER.** Reordering a template's
1022   parameters would reorder no saved instance (the merge keeps an
1023   instance's order), so groups are contiguous runs as the parameters
1024   stand; a name may come back after another run (the Group node's mode
1025   rows sit either side of Invert/Highlight) and simply draws a line each
1026   time it changes.
1027 - **What the groups are**, by convention across the templates: a node's
1028   point-group filter (`Group`) is `where`; an operator's settings, its
1029   output naming, its display switches and its mode-specific rows are
1030   runs of their own (the Attribute node: target, value, amount, where,
1031   range…; Page Shape: geometry, fill, stroke).
1032 - **A separator names no parameter**: its key and value are empty, so the
1033   pane's write-back and `param_row_at` find nothing by it, and it shifts
1034   no index between the pane's rows and `param_display`'s, which both
1035   include it.
1036 
1037 `the_params_pane_separates_groups_of_parameters` is the test.
1038 
1039 ### Conditional parameter rows
1040 
1041 A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
1042 values deciding whether the params pane shows it: `Mode == Twist`,
1043 `Mode == Twist|Bend` for any-of, `Mode != Bleed` for unless, ` && ` between
1044 clauses, and ` || ` between alternatives, binding looser than ` && `
1045 (`operation == Clip || operation == Remap && from_range == Manual`; since
1046 2026-10-06), compared case-insensitively. Empty means always, which is what most
1047 parameters have. `param_visible` evaluates it and `param_display` filters on
1048 it.
1049 
1050 It exists because collapsing the Houdini operator set into fewer nodes traded
1051 node count for parameter count — `attribute` reached seventeen parameters, of
1052 which seven apply at once. Phrased the positive way round (unlike Houdini's
1053 `hideWhen`) because a template author is describing when a control APPLIES.
1054 
1055 Two rules worth knowing. A condition that does not parse, or names a parameter
1056 the node does not have, HIDES its row: a template bug should be visible, not
1057 silent — and `test_the_shipped_templates_only_name_parameters_they_have` walks
1058 every shipped template to catch exactly that. And hiding a row never touches
1059 its value: write-back resolves rows by display key rather than position, so a
1060 hidden parameter is simply not reported and comes back as it was.
1061 
1062 `merge_template_defs` carries `show_when` from the template like the rest of
1063 the UI metadata — the template owns when a control applies, the instance owns
1064 its value.
1065 
1066 ### Mesh export
1067 
1068 `src/export.rs` writes STL (binary and ASCII) and OBJ; `src/export_cli.rs` is
1069 the `--export` mode; the `export` NODE is a pass-through that writes when its
1070 Export button is pressed — never on evaluation, which happens on every redraw
1071 and every frame of a solve.
1072 
1073 The formats are not the same picture of a mesh. **OBJ keeps the topology**:
1074 points are written once, faces reference them, a quad stays a quad. **STL keeps
1075 only triangles** — it has no shared points, so everything fans and comes back
1076 welded-by-position at best. Neither carries attributes; the project file and
1077 the sim cache are what preserve a simulation's state.
1078 
1079 Coordinates are written as they are, scaled only by the node's Scale.
1080 The World Unit is a DECLARATION, not a conversion (see the Guides node), and
1081 export keeps that promise: geometry modelled at 20 units across writes as 20,
1082 and the slicer is told those are millimetres.
1083 
1084 A button row of the params pane dispatches through `State::run_param_button`
1085 by the parameter's NAME (`export`, `detect_range`), which carries no node —
1086 `run_export` resolves the node from the current selection, which is sound
1087 because the pressed button can only be on the node the pane is showing.
1088 Until 2026-10-06 the press went to `execute_menu_action` by the pane's key,
1089 which since the names became identifiers was `export` — a label nothing
1090 matches — so the Export button silently did nothing for five days.
1091 `execute_menu_action("Export")` stays for MCP's `menu_action`.
1092 
1093 ### Transfer carries groups, and Remesh has a copy of it
1094 
1095 `geometry::transfer_onto` is the one transfer (2026-09-30): onto each
1096 point of the target (in the node's Group, when that names one; within
1097 Maximum Distance of its nearest source point, when that is above zero)
1098 the nearest source point's attributes and, with **Transfer Groups** on,
1099 its membership in the named **Groups** — every group the source has when
1100 none is named. A membership is COPIED, joining and leaving alike: a
1101 group carried this way is the source's group laid over the target, not a
1102 union with what the target had, and a group the target lacks is created
1103 so it exists everywhere the attribute columns do. A group the SOURCE
1104 lacks is not touched. The switch is off by default and a node from
1105 before the row is off, so a saved Transfer carries what it carried.
1106 
1107 **Remesh has the same transfer inside it**, its `Transfer` toggle (off
1108 by default) with From, Attributes, Transfer Groups, Groups and Maximum
1109 Distance rows shown while it is on — since the Remesh became a subnet
1110 (below) that is its `transfer1` child, an ordinary Transfer node whose
1111 rows are expressions on the subnet's, behind a switch on `chi("../transfer")`;
1112 the native node's copy is `remesh_transfer`: once the mesh is
1113 remeshed, a source's attributes and groups laid over the NEW points by
1114 nearest point — the node's own input when From names nothing, which
1115 needs no wire, else the node it names. What a point was rides a split by
1116 interpolation and a collapse by the survivor, but not everything; read
1117 back off the mesh as it was before, or off a shape that still carries
1118 it, a group is kept at every step of a solve with no Transfer node wired
1119 in after. A From it cannot resolve is an error on the node. (It was on
1120 the Relax node for an hour, from its Rest — the user's slip, taken back
1121 the same day.) `transfer_carries_groups_and_remesh_has_a_copy` is the
1122 test, the rule on hand-built points and both nodes through their rows —
1123 the Remesh both ways, native and subnet.
1124 
1125 ### Diffuse and Concentrate (since 2026-10-06)
1126 
1127 `src/surface_flow.rs`, `nodes/diffuse.json`, `nodes/concentrate.json`: a
1128 point attribute flowing over the SURFACE of a mesh, as a step of a
1129 simulation. The Neighbour node's modes of the same names stay as they were
1130 — a filter toward or away from the plain average of the neighbours, which
1131 depends on the tessellation, conserves nothing and (Concentrate) grows
1132 without bound. These two are the simulation's versions:
1133 
1134 - **Both run on the surface's Laplacian** (`Surface::of`): each point's
1135   area (a third of each triangle around it) and each edge's cotangent
1136   weight, polygons fan-triangulated. A negative weight is taken as zero —
1137   exactness on obtuse triangles traded for the maximum principle. So
1138   **Rate is in square world units per frame** and means the same on any
1139   mesh: height on a unit sphere is the Laplacian's eigenfunction, and one
1140   step of Rate 0.1 scales it by 0.840 / 0.836 / 0.834 at 12x16 / 24x32 /
1141   48x64 against the exact 1/1.2
1142   (`diffuse_is_a_rate_over_the_surface_and_not_the_tessellation`).
1143 - **Both are flux**: what leaves a point along an edge arrives at the
1144   other, so the total (value times area) is conserved exactly and an open
1145   boundary lets nothing out. Points outside the Group hold their values and
1146   feed or drain their neighbours (a fixed temperature at a plate's edge),
1147   so the total is conserved only over the whole mesh.
1148 - **Diffuse is one implicit step** (backward Euler, Jacobi-preconditioned
1149   CG in f64): stable at any Rate.
1150 - **Concentrate flows UP a gradient** — of the attribute itself, or of
1151   **Follow** (one number, or as wide as the attribute: chemotaxis).
1152   **Response** Difference is the heat equation run backward; Amount
1153   multiplies by what the giver holds (Keller–Segel aggregation: against a
1154   Diffuse at the same Rate it gathers above one and spreads below). Run
1155   backward the equation has no stable form; what holds it is a LIMITER: a
1156   point gives at most what it holds above the floor — zero, or the lowest
1157   value if some are negative — so a point is emptied, never overdrawn.
1158   Zero and not the lowest value, or a uniform density could not follow
1159   anything. Explicit, cut into internal steps by its stiffness (at most
1160   `CONCENTRATE_STEPS_MAX`).
1161 - **Per Frame** (on in the templates) reads the simnet's `dt`, as the
1162   Attribute node's does: one substep of Rate 0.1 leaves the pole at 0.836,
1163   four at 0.826, four without it at 0.484
1164   (`a_diffuse_in_a_simnet_spreads_a_frame_whatever_the_substeps`). **Rate
1165   By** scales the rate per point, by the mean of an edge's two ends so the
1166   flux stays symmetric. Integers round on the way back.
1167 - A mesh with no triangles is an error on the node ("no surface to flow
1168   over"), as is a missing attribute, Rate By or Follow; the geometry then
1169   passes through untouched.
1170 
1171 The tests are in the module itself.
1172 
1173 ### Mold tooling
1174 
1175 `src/mold.rs` is the first GEM operator, ported from the plugin's
1176 `gem_mold_shell`. Its four parameters are that node's — Maximum Thickness,
1177 Minimum Thickness, Remesh Division Size, Thickness Ramp — and the production
1178 notes from the original cast give the numbers that worked (0.75 / 0.6 / 0.9,
1179 linear), which are the template's defaults.
1180 
1181 **Thickness varies with curvature**, which is the whole point and the reason
1182 the `volume` node's uniform shell will not do. The plugin does it with an
1183 `im_ramp_scalar` named `curvature_to_thickness`; this does the same three
1184 steps — remesh to the division size, measure curvature per point, map it
1185 through a ramp into the thickness range.
1186 
1187 `curvature` is a signed DIMENSIONLESS measure in roughly -1..1: the mean of
1188 `dot(normalize(neighbour - p), n)`. Negative is convex, positive concave. Every
1189 term is a dot product of two unit vectors, so it does not move when the model
1190 is scaled or re-tessellated — which matters because thickness is chosen from
1191 it, and a measure that shifted with the remesh division size would give a shell
1192 whose thickness changed every time you re-tessellated. A true mean curvature in
1193 1/length would do exactly that.
1194 
1195 The curvature-to-thickness map is affine over a FIXED -1..1, not normalized
1196 over the range present in the model. Normalizing would make one part's
1197 thickness depend on how curved the rest of it is, so adding a sharp corner
1198 somewhere would thin the whole shell. Concave regions get the maximum: a mould
1199 is weakest where it cups inward, with least material behind it and the most
1200 leverage on it when the cast is pulled.
1201 
1202 The inner surface is a DISPLACEMENT along each point's normal, not a field
1203 offset — a signed distance field offsets by a constant and cannot vary per
1204 point. The cost is the usual one: where thickness exceeds the local radius of
1205 curvature the inner surface folds through itself. That is what the
1206 minimum/maximum range is for; it is a range because the geometry constrains it,
1207 not because one number was hard to pick.
1208 
1209 There is no ramp PARAMETER type in this app (cce-ui has the widget, nothing
1210 wires it as a node parameter), so the free-form float ramp is ported as the
1211 three-way choice the falloff parameters already use. Linear is the default
1212 because linear is what the cast that worked used.
1213 
1214 ### Parameter expressions (`ch()` references, Houdini's way)
1215 
1216 `src/expr.rs` is the expression language and `geometry.rs`'s `TreeScope`
1217 is what binds it to the node tree. **A parameter holds a value or an
1218 expression, and `ParamDef::expr` says which** — a flag, not a guess about
1219 the text, because a kernel's Code contains `chf(`, a node name is an
1220 identifier and `0.5` parses as an expression too. Houdini makes the same
1221 choice (a parm has a channel or it does not). An expression parameter is
1222 evaluated every time its node is: `resolve_param_refs(root, node, frame,
1223 error)` hands back a clone whose `expr` params are VALUES, at the top of
1224 `generate_single_node_geometry_with_errors`, the scene walk's `visit`, and
1225 the kernel path's parent read.
1226 
1227 **Paths are Houdini's.** Relative to the node holding the expression: a bare
1228 name is the node's OWN parameter, `..` its parent, `../sphere1/radius` a
1229 sibling's, a leading `/` the root. `.x` / `.y` / `.z` reads a float2's
1230 or float3's component. `ch()` / `chf()` read a number (a toggle 1 or 0, a choice its
1231 option INDEX — `chi("../method")` is what lets a subnet's dropdown drive a
1232 child switch's Index), `chi()` truncates, `chb()` is 1 or 0, `chs()` the
1233 string (a choice's option text). The rest is `+ - * / % ^`, comparisons,
1234 `&& || !`, `$F` (the evaluation's frame), strings with `+`, and a fixed
1235 function set (`if(c, a, b)`, `clamp`, `fit`, `lerp`, `min`/`max`, `rand(seed)`,
1236 the usual math). No ternary — `:` separates a float2's or float3's
1237 components, which are an expression each (`chf("../a/size.x"):0:0`). An expression that
1238 reads an expression follows the chain; a circle is an error on the node,
1239 never a stack overflow. The written-back value is formatted for the
1240 TARGET row (`format_for_param`): a number into a toggle is `true`/`false`,
1241 into a choice its option name, into a spinbox an integer.
1242 
1243 **Until 2026-09-24 a bare `ch("Name")` meant the PARENT's parameter** (the
1244 whole value had to be one reference, nothing else). `Project::format` is
1245 the version that tells the two apart: 0 (absent) loads through
1246 `migrate_param_refs`, which turns each old reference into an expression
1247 with `../` added to a bare name — beside `sanitize_node_names` on every
1248 load path, and never twice, since a bare name in a format-1 file is the
1249 node's own parameter. It is a step of `Project::migrate_format`, which
1250 takes a file through every step it is behind. **Format 2** (2026-10-01):
1251 the generators' normal attribute is `N` — the Sphere, Box, Plane and
1252 their kin wrote `Norm`, where the Normal node, the exporter and a
1253 wrangle's `@N` already said `N`. **Format 3** (the same day): their
1254 texture coordinates are `uv`, where they were `UV`. Both are
1255 `Project::rename_attribute` steps, which rewrite what names the old name
1256 in an older save: an attribute row, a name in a comma list of attributes
1257 (`Attributes`), `@old` in a wrangle's Code as a whole name (not `@Normal`,
1258 not `@UVW`) — and never a choice row, so the Sphere's Method keeps its
1259 `UV` option. Once, by the version, so an attribute someone names `Norm` or
1260 `UV` afterwards is theirs (`a_save_naming_norm_or_uv_names_n_or_uv`).
1261 **Format 4** (the same day): parameter names are identifiers — see "A
1262 parameter has a name and a label". **Format 5** (2026-10-02): the root is
1263 the object level and an older save's geometry goes into a Geometry node —
1264 see "The root is the object level". **Format 6** (2026-10-06): a range is
1265 one `float2` (`Project::migrate_range_rows`). The Attribute node's From
1266 Min / From Max join as From, To Min / To Max as To, and To Max is copied to
1267 Normalize To, which Normalize read it as; Visualize's From / To join as
1268 Manual Range. Each pair is joined as written, the whole an expression when
1269 either half was, a missing half the default. A channel path to an old row
1270 is RESOLVED from its holder and rewritten — `from_max` → `from.y`, `to_max`
1271 → `to.y` or `normalize_to` on a node set to Normalize, Visualize's `to` →
1272 `manual_range.y` — since `from` and `to` are also wires on Transfer, Copy
1273 and Distance, which are left alone. Checked on the user's project (four
1274 Attribute nodes, none a Remap): the old build and the new export the same
1275 mesh at frames 1 and 30. **Format 7** (the same day): Composite's Length
1276 is the length of Name — see "Composite writes a Result" under "Pull
1277 arrows". **Format 8** (the same day): Develop's **Direction** is an
1278 `attribute` row naming the vector points move along, `N` by default,
1279 where it was a Normal / Attribute choice beside a Source row
1280 (`Project::migrate_develop_direction`: Normal → `N`, Attribute → what
1281 Source named, the Source row dropped). `N` on an input that carries none
1282 is the surface's point normals, as a wrangle's `@N` reads it, so `N` is
1283 "along the normal" either way; any other name the input lacks is an error
1284 on the node. Checked on the user's project: the old build and the new
1285 export the same mesh at frames 1 and 30, and the develop node alone.
1286 `develop_moves_along_the_attribute_its_direction_names` and
1287 `an_older_develop_direction_becomes_an_attribute_name` are the tests.
1288 Templates go through
1289 `infer_template_exprs` instead: a default that READS as a reference is one
1290 (`embryo.json` says `chf("../radius")` now). The same inference applies to a
1291 value typed into a plain row or scripted through `set_param`: a reference
1292 becomes an expression; bare arithmetic does not, and is asked for through
1293 the row menu.
1294 
1295 **The params pane's right-click menu** (`param_row_at` → `open_param_context_menu`,
1296 a fifth `context_menu` consumer with the `*_menu_actions` +
1297 `handle_*_menu_click` contract, and `run_param_action` as the one entry the
1298 menu and the tests share) is Houdini's: **Copy Parameter**, **Paste
1299 Relative Reference** (`relative_ref_path`: `../sphere1`), **Paste Absolute
1300 Reference** (`/sphere1`), and **Edit Expression** / **Delete Expression** —
1301 the latter bakes the CURRENT value back as a value, as Delete Channels
1302 does. `copied_param` holds a node ID, not a path, so a rename between copy
1303 and paste still pastes the right path. **Header rows read the
1304 parameter out** (since 2026-09-28, `param_menu_rows`): `Name:` is the
1305 parameter's name, what a `ch()` path spells, with `Label:` under it — what
1306 the pane shows — when there is one (every template parameter, since the
1307 names became identifiers; a parameter added without one shows its name),
1308 then **what the
1309 parameter does** (since 2026-09-30): the template's `description`, a
1310 sentence or two in prose, wrapped to `PARAM_DESCRIPTION_WIDTH` (44)
1311 characters over as many unprefixed rows as it takes, since the menu is as
1312 wide as its widest row. It is the TEMPLATE's, like the label: the merge
1313 hands it to every instance (`adopt_ui_from`), a subnet template's child
1314 takes its base template's, and it is read from a template file and never
1315 written, so a save carries none and `sim_solve_key` does not see it.
1316 Every parameter a template ships has one —
1317 `the_row_menu_says_what_a_parameter_does` reads the raw files — so a new
1318 parameter needs its description written with it. `Control:` is the
1319 control the pane DRAWS for the row (slider, spinbox, dropdown, toggle,
1320 text box, text box with picker, code editor, button) and `Type:` the type
1321 of value that control SETS, in a programmer's terms (float, float3,
1322 integer, boolean, enum, string, and node / attribute / group for a text
1323 that names one) — `control_and_type`, read off the row as the pane shows
1324 it, since the Attribute node's Value is a text parameter presented as
1325 sliders over a float3 and an expression is a text box whatever its kind.
1326 Until later the same day Control was the kind's name, Type the raw type
1327 string and a third `Value:` row the type of the text held, which read
1328 `text` / `text` / `string` over what was plainly a slider setting a
1329 vector; the Value row is gone, the Type row being the value's type, and
1330 the raw string's content is the range and options rows. `Expression:` is
1331 the row's expression FLAG as `true` / `false` (the bit Edit Expression
1332 sets and Delete Expression clears), `Invalid:` the reason a kept text does
1333 not fit its kind, shown only then, `Default:`
1334 the template's value as written there
1335 (`State::template_default`, which takes a subnet template's override for
1336 a child inside an instance — the Embryo's `sphere1` defaults its Radius to
1337 `chf("../radius")` — and is absent for a parameter no template names),
1338 then for a slider, float3 or spinbox `Min:` / `Max:` / `Step:` as the
1339 template DECLARES them (`ParamDef::declared_range`, an inline
1340 `slider:-2:2` included, `none` where it says nothing) followed by
1341 `Range: lo..hi` with its step as the pane APPLIES it (`ParamDef::range`,
1342 the numbers `param_display` builds the pane's row from — the declared
1343 ends, else the pane's defaults — or the presented row's own range for a
1344 parameter that has none, the Value row's `VALUE_ROW_RANGE`),
1345 `Options: a, b, c` for a choice, and `Shown when:`
1346 with the row's `show_when` condition when it has one. They are the context menu's header rows —
1347 dimmed, never hovered — and `ParamMenuAction::Info` runs nothing. The paste writes `chs()` when the
1348 target row holds text or a choice and `ch()` otherwise, by the TARGET,
1349 because that is what the value has to fit. Expression rows draw with a
1350 green tint (`render.rs`, PARAM_IDX arm) and as text in the pane
1351 (`param_display`), since a slider cannot hold one.
1352 
1353 **A rename carries every reference to the node** (`rename_node_in_tree`):
1354 expression paths that pass through it are rewritten textually
1355 (`expr::rewrite_paths`, so spacing survives), resolved from where each
1356 stands BEFORE the name changes since a path is names; sibling wires whose
1357 value is the old name follow, as the load-time sanitizer rewrites them;
1358 and the active camera. A same-named node elsewhere is not this one.
1359 
1360 ### Bypass
1361 
1362 A node can be BYPASSED (since 2026-09-29): it stays in the graph, wired as
1363 it was, and does nothing. `FsNode::bypassed`, and `geometry::is_bypassed` is
1364 the one reading of it.
1365 
1366 - **What reads a bypassed node gets what the node reads**: its `Input`,
1367   untouched. A node with no input — a generator — gives nothing, which is
1368   what a generator switched off should give. A bypassed subnet or simnet
1369   passes its Input and its children are not run.
1370 - **It is decided ahead of the node's own parameters.** The check sits at
1371   the top of `generate_single_node_geometry_with_errors`, before
1372   `resolve_param_refs`, so an expression that would fail on the node is
1373   not evaluated and not reported: bypassing is how a broken node is taken
1374   out of a chain while it is fixed.
1375 - **In three places, because there are three dispatches**: that function,
1376   the scene walk's `visit` (which hands nodes to their resolvers itself —
1377   a shown, bypassed node draws what it passes, is still counted for the
1378   nodes placed by index, and is not gone into), and `page::resolve_page`
1379   for the 2D context.
1380 - **`input`, `output` and `camera` ignore it.** The first two are a
1381   subnet's plumbing, and a bypassed `input` would be a chain that reads
1382   nothing.
1383 - **The flag is written only when it is set** (`skip_serializing_if`), so
1384   a file that never bypassed anything is byte for byte the file it was and
1385   `sim_solve_key`, a hash of the simnet's JSON, restarts a simulation when
1386   a node in its chain is bypassed and not otherwise.
1387 
1388 `State::set_bypassed(slots, bool)` is the one writer, and three things
1389 call it: the **`bypass_node`** command (`b`, the network's, acting on the
1390 selection, the whole of which follows the FIRST node's flag as `e` does
1391 for the geometry flag); the node's right-click menu (**Bypass** / **Stop
1392 Bypassing**); and MCP's `toggle_bypass`. The id is not `toggle_bypass`
1393 because the `toggle_*` family is the settings' switches, which
1394 `dialog_toggle_rows_cover_every_toggle_command` holds to a table this has
1395 no place in.
1396 
1397 A bypassed node wears amber (`render::BYPASS_TINT`): its roll tinted
1398 through the bevel's own tint channel, and a flat bar down its left side,
1399 drawn after the bodies with the geometry toggles. The bar is what still
1400 says so while the node is selected and its roll is the selection's colour.
1401 The node is found by `GraphController::node_at` at the body's centre.
1402 Nothing in cce-ui changed.
1403 
1404 `a_bypassed_node_passes_its_input_through` covers the geometry resolver
1405 and the scene walk, `a_bypassed_page_node_passes_its_sheet_through` the
1406 page resolver — with each node of a sheet, grid, border and export chain
1407 bypassed in turn — and `bypass_is_one_flag_however_it_is_asked_for` the
1408 three ways of asking.
1409 
1410 ### Deleting a node splices it out
1411 
1412 `State::delete_node` rewires around the node before it goes (since
1413 2026-10-01, `app::splice_out`): every sibling wire that named it — an
1414 `Input` or a second operand, any plain `node` parameter — takes the name
1415 the deleted node's own `Input` carried, so deleting B from A → B → C
1416 leaves A → C. Every way of deleting goes through it (Delete over a
1417 selection, Cut, the node menu, MCP); a selection is deleted highest slot
1418 first, one splice at a time, so a run of chained nodes leaves its ends
1419 joined. Nothing is rewired when the node's Input is empty (a generator),
1420 an expression or hidden — those wires are left naming it, as before —
1421 and a node is never wired to itself. The rewiring is in the deletion's
1422 undo step, since a structure step holds the wires of every node it
1423 touches. `deleting_a_wired_node_connects_its_neighbours` is the test.
1424 
1425 ### Adding a node on a wire splices it in
1426 
1427 Add Node (the dialog's AddNode pick, from Tab or the network menu) on a
1428 FREE grid-cursor cell that a wire into an Input runs through wires the new
1429 node into that chain (since 2026-10-01): A → C becomes A → new → C. Which
1430 wire is cce-ui's `GraphController::input_wire_through_cell`, the hit test a
1431 node dragged onto a wire uses, asked about the body the new node will have
1432 — so adding and dropping agree about what is on a wire, in every wire
1433 style. It is asked BEFORE the add, since the new node's own wires would
1434 touch the cell after. The rewiring is `app::splice_into_wire`, the one the
1435 drag drop runs: both Inputs or neither, so a node with no
1436 Input — a generator — is added beside the wire and cuts nothing. MCP's
1437 `add_node` places at the coordinates it is given and does not splice.
1438 `a_node_added_on_a_wire_is_wired_into_its_chain` is the test.
1439 
1440 **A paste splices the same way** (`paste_nodes`, the same day): one node,
1441 or a pasted set that is ONE chain — one head whose Input reads no other
1442 pasted node, one tail no other pasted node reads — goes into the wire
1443 whole (`splice_chain_into_wire`); any other shape is pasted beside it.
1444 That needed a fix it could not work without: a paste KEPT ITS NAMES, so a
1445 pasted `transform1` stood beside the original and a wire naming it found
1446 the original. A pasted node whose name is taken now takes the next free
1447 one, and the wires between pasted nodes follow; a wire to a node that was
1448 not copied still names that node.
1449 `a_paste_on_a_wire_is_spliced_into_its_chain` is the test.
1450 
1451 ### Dropping a node on a node swaps their places (since 2026-10-06)
1452 
1453 A node dragged onto another node trades places with it, connections and
1454 all — the chain's ORDER changes, not just the picture: in I → A → B → C,
1455 B dropped on A gives I → B → A → C. The network editor turns cce-ui's
1456 `Graph::set_swap_on_drop` on (see its CLAUDE.md, "A node dropped on a node
1457 can swap with it"); the widget trades the two cells, which the drag's
1458 position write-back carries into the tree, and the release drains
1459 `take_pending_swap` into `app::swap_places`, which trades the wires.
1460 
1461 - **The rule is a renaming σ (A ↔ B) of every wire**: a third node's wire
1462   w becomes σ(w); A's k-th wire becomes σ of B's k-th and B's σ of A's,
1463   port for port (`node` parameters in order, as `node_wires` numbers
1464   them), so a wire between the two turns round. A port only one of them
1465   has keeps its own wire, σ'd — a Relax dropped on a Pull keeps its Rest.
1466   An expression wire moves as it is, unrewritten.
1467 - **One undo step**: positions and wires are both structure, noticed at
1468   the end of the event (`record_structure_changes`).
1469 - **A multi-node drag does not swap** — `set_swap_on_drop(false)` while a
1470   `NodeDragGroup` is armed — since the widget drags one node and the rest
1471   follow by an offset.
1472 - **The swap wins over a splice**: a node's own wires run into its body,
1473   so a ghost on a node always touches one.
1474 - **A dragged node always snaps to a cell it could land on** (the same
1475   day): `State::grid_snap_enabled` is always on, and config.kdl's
1476   `style.surface.graph.grid_snap` is not read — off on the user's machine,
1477   it let a dragged node float freely and land somewhere else. With swap on
1478   every crossing is one it could land on (a free one moves it, a node's
1479   swaps), so the ghost goes where the pointer is nearest; in a group drag,
1480   where swap is off, it skips taken crossings (cce-ui's `drag_update`).
1481 
1482 `dropping_a_node_on_a_node_swaps_their_places` drives it by pointer, undo
1483 included; `swapping_places_trades_wires_port_for_port` is the rule.
1484 
1485 ### Sibling-first inputs and the Switch node
1486 
1487 Two pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
1488 the way a Houdini HDA is — the Embryo is the first to be recomposed that
1489 way — both in `src/geometry.rs`:
1490 
1491 - **`find_input_node(root, target, name)` looks for a SIBLING first, then
1492   on each level around the node, nearest first (since 2026-09-30), then
1493   anywhere.** The middle step is what lets a child of a subnet name a node
1494   BESIDE the subnet — the Remesh subnet's Transfer reading its From —
1495   and find that one, not the first of the name in the tree. Every resolver used to search the whole tree from the top, so
1496   inside the second instance of a subnet a child wired to "input1" found the
1497   first instance's; the opencl and output resolvers had each grown a
1498   sibling-first lookup of their own to dodge exactly that. Every wire goes
1499   through it now, most as `param_node(root, target, "Input")` (see
1500   "Parameter kinds"). That was claimed on 2026-09-21 and was not quite
1501   true until 2026-09-26: Collision's `Collider`, Relax's `Rest`, the page
1502   chain's `Input` and the params pane's group/attribute pickers still
1503   searched the whole tree by name, so in a second copy of a subnet they
1504   found the first copy's node (`a_rest_wire_resolves_to_its_own_sibling`).
1505   Which rows GET a picker is the parameter's kind — `attribute` / `group`,
1506   see "Parameter kinds" — not a table of row names.
1507 - **`switch`** passes one of `Input`, `Input 2` … `Input 4` by `Index`,
1508   clamped; an empty slot passes nothing. Every one of them draws a wire,
1509   into its own port, as every second operand does (Boolean's With, Copy's
1510   To, Transfer's From) — since 2026-09-30; until then only `Input` did, so
1511   a second operand was a connection with no line. `app::node_wires` is the
1512   rule: every `node` parameter, in order, the k-th into port k, handed to
1513   the graph typed `node` (cce-ui's `wire_pairs`); a node gets as many input
1514   ports as it has wires where its template declared fewer (Relax's Rest,
1515   Collision's Collider, the Remesh's From). A wire whose row is hidden
1516   keeps its port and draws no line. An expression wire is drawn to what it
1517   evaluates to at the current frame (`node_wires_at`) — the Remesh subnet's
1518   transfer reads its From through `if(chs("../from"), …, "input1")` and is
1519   drawn from input1 — and to nothing when that fails. A connection dropped on
1520   port k sets the k-th wire (`State::connect_port`).
1521   `every_wire_is_drawn_into_its_own_port` is the test.
1522 
1523 ### Sphere, Box, Plane and Extrude are native (2026-09-24)
1524 
1525 `src/shapes.rs` holds the four shapes that were kernel subnets — Phase 7
1526 step 3 of `shapeshifter.md`. Each was `input → opencl → output` with a
1527 kernel that ran under `if (id == 0)`: one work item doing loops, then a
1528 weld by position on the way back that threw away every shared point the
1529 loop had known. Native, each builds welded points and real primitives —
1530 a quad stays a quad — costs no JIT compile and needs no OpenCL at all.
1531 The parameter surfaces are the templates' own, so a saved instance keeps
1532 its values. The templates are plain native nodes now (`"type": "sphere"`
1533 and so on, no children); the Embryo is the one subnet template left, and
1534 its `sphere1` child resolves to the native Sphere with the template's
1535 whole surface under the Embryo's overrides.
1536 
1537 **The Sphere's Method** — `UV`, `Icosphere`, `Cube` — survives as it was:
1538 UV is Rows x Columns through `sphere_detail`; Icosphere splits each of the
1539 icosahedron's 20 faces into Frequency^2 triangles by integer barycentric
1540 weights; Cube lays a Resolution x Resolution grid on each face and pushes
1541 it out through the spherified-cube map, and builds QUADS where the kernel
1542 fanned them. Welded counts are `2 + (rows - 1) * cols`, `10 f^2 + 2` and
1543 `6 r^2 + 2`, which `sphere_method_builds_a_uv_ico_or_cube_sphere` asserts
1544 along with closedness. Welding is by a QUANTIZED position key (1e-5)
1545 rather than by trusting bit-identical arithmetic across faces: the kernel
1546 summed weights in one fixed expression so shared corners landed on the
1547 same bits, then welded at 1e-4 anyway; a quantized key is the same
1548 guarantee stated once. Colour is the kernel's — the SIGNED normal folded
1549 into 0..1, world-anchored — with Color on, `DEFAULT_COLOR` off.
1550 
1551 **A bare `sphere` node with no Center parameters is placed by index**
1552 (`index_center`), the way Line and Points still are: that is the tests'
1553 hand-built `ref_node("sphere", [Radius])`, and every node that came through
1554 a template or a load carries Center X/Y/Z and sits where they say.
1555 
1556 **Box** is eight corners and six quads about a float3 Center (new; the
1557 kernel hard-coded (0, 0.55, 0)), normals on the VERTICES like `box_detail`;
1558 Wireframe draws the twelve edges as bars and the corners as small cubes,
1559 as the kernel did. Its unused `Input` is gone. **Plane** is the kernel's
1560 sheet with its colour gradient; Grid is the same sheet with a float3 Center
1561 and no gradient, two nodes for history's sake.
1562 
1563 **Extrude extrudes AS A WHOLE**, which is the one semantic change: every
1564 point moves along its point normal, the input's primitives become the
1565 top, one quad wall rises from each BOUNDARY edge, and Keep Base keeps the
1566 originals wound the other way — a sheet becomes a closed slab, a closed
1567 surface a two-skinned shell. The kernel extruded every triangle on its
1568 own and welded the prisms back together, which put a wall along every
1569 interior edge. Point attributes and groups ride to the top copies; the
1570 kernel's 15% darker walls were a per-corner colour a soup could hold and
1571 shared points cannot, and are gone.
1572 
1573 **Saved kernel subnets migrate on load.** `nativize_kernel_subnets` in
1574 `merge_template_defs` turns a `node` whose children include an `opencl`
1575 child and whose base name is one of the four into the native node: id,
1576 name, position, flag and values stay, the children go, and a parameter the
1577 native template lacks goes with them. Only when the `opencl` child is
1578 actually there, so a subnet someone built by hand and called "sphere2"
1579 keeps what is inside it. The bundled `default_project.json` and
1580 `project.json` were converted in place, and
1581 `test_loader_merges_new_template_params` is the migration's test. The
1582 `opencl` node and both kernel backends were retired the same day (above).
1583 
1584 ### The Embryo node is a template of nodes
1585 
1586 `nodes/embryo.json` is hou-control's `developer_embryo`, the Developer
1587 family's first Pre-Simulation operator — "the seed geometry a simulation
1588 starts from" — as a SUBNET of ten ordinary nodes wired the way the HDA's
1589 network is, its controls reaching the children through parameter references
1590 (above). Dive in and the pipeline is there to read, break and reuse: `input1`
1591 and a `sphere1` (Radius `chf("../radius")`, Rows and Columns
1592 `chi("../base_resolution")`) behind `source1`, a `switch` whose Index is
1593 `chi("../source")`; `scatter1` in Surface mode reading the Scatter folder's
1594 controls, `hull1` behind it, and `method1`, a switch on `chi("../method")`
1595 between the source and the hull; then `relax1` in Repel mode, `subdivide1`,
1596 `normal1`, `output1`. The defaults are the HDA's, and
1597 `embryo_template_builds_a_sphere_a_hull_or_the_input` drives the template
1598 end to end.
1599 
1600 It was a native node for one day (2026-09-21, `src/embryo.rs`, a pipeline in
1601 Rust), which is the wrong shape for this app: CLAUDE.md refuses `gem_graph`
1602 for the same reason, and a node you cannot dive into cannot be learned from.
1603 Recomposing it needed four reusable pieces, all of which outlive it:
1604 parameter references (now expressions, their own section above) and the
1605 `switch` node, the
1606 `hull` node (`src/hull.rs` — the incremental convex hull; points that span
1607 no volume pass through), and two modes on existing nodes (`src/scatter.rs`):
1608 **Scatter's Surface mode** (points ON the surface by area, seeded, optionally
1609 pushed apart across it with a radius derived from the area per point — the
1610 Scatter SOP with Relax Points) beside its original Volume mode, and
1611 **Relax's Repel mode** (spheres of Radius pushed apart, sliding in the
1612 tangent plane unless In 3D Space; zero iterations is off) beside its
1613 original Springs mode. A native `embryo` in an older save is recomposed on
1614 load (`recompose_native_embryo` in `merge_template_defs`): id, name,
1615 position, flag and values carry over, the template's children arrive fresh.
1616 
1617 Two deliberate differences from the HDA. **Subdivide does not smooth**: it
1618 is this app's `remesh::subdivide` (four triangles per triangle, points
1619 unmoved), where the HDA runs Catmull-Clark — same parameter, one operation
1620 rather than two under one name. **The second input is the first**: the HDA
1621 read its Source from input 2, and this app's nodes name one Input.
1622 
1623 **Exactly one child of the template draws, `normal1`**, the last real
1624 node. A subnet viewed from
1625 OUTSIDE shows its internals by their own flags (output children draw only
1626 at the displayed level), so with every chain node visible the hull drew
1627 five times over, each draw re-evaluating the pipeline: 2.4 s per edit on a
1628 release build, 0.1 s with one. Template child specs therefore carry
1629 `geometry_visible` through `load_fs_tree` (absent means on, as before).
1630 
1631 Nesting a subnet template inside a template (the Embryo's `sphere1` is the
1632 Sphere template) is what made `load_fs_tree`'s child resolution recursive:
1633 a base that is itself a subnet brings raw children of its own, and those
1634 resolve the same way, or the nested sphere's kernel node arrived with only
1635 the params its override named. Depth-bounded, so a template that contained
1636 itself would fail rather than recurse forever.
1637 
1638 ### The Remesh node is a subnet, and Repeat is a loop (2026-09-30)
1639 
1640 The Remesh is a template of nodes now, as the Embryo is, so it can be dived
1641 into and its passes read, bypassed and rewired. `nodes/remesh.json`:
1642 
1643 ```
1644 remesh1 (node)  input1 → repeat1 → transfer1 ─┐
1645                                └──────────── transfer_switch1 (on Transfer) → output1
1646 repeat1 (repeat, Iterations = chi("../iterations"), Stop When Unchanged on)
1647                 input1 → split1 → collapse1 → flip1 → relax1 → project1 → output1
1648                 seed1 ───────────────────────────────────────┘ (Surface)
1649 ```
1650 
1651 The subnet makes the mesh the native remesh makes, BIT FOR BIT — points,
1652 identities, the id counter, primitives, attributes, groups, transfer
1653 included — and `the_remesh_subnet_is_the_remesh` holds it there, twice in
1654 a row as a solve's steps are. That rests on three things: a pass that
1655 changes nothing hands its input back as it came; one that changes
1656 something converts to the remesher's `Mesh` and back, and the conversion
1657 keeps point and triangle ORDER (compaction is monotone, so the edge list
1658 every pass sorts by index comes out the same); and `Mesh::from_detail`
1659 takes the id counter the detail carries, not one past the highest id left
1660 — otherwise a point a collapse removed had its id handed out again by the
1661 next pass's split (which the native remesh, holding one `Mesh` throughout,
1662 never did — and which, across the frames of a solve, it DID do, so the
1663 change is a fix for the native node too). With no relaxation and nothing to
1664 do the input comes back untouched, as the native node's does: every pass
1665 returns its input, and Project hands back a mesh that IS its Surface.
1666 
1667 The pieces, all reusable on their own:
1668 
1669 - **Repeat** (`repeat`, `nodes/repeat.json`) — a loop: its chain run
1670   Iterations times (at most `REPEAT_MAX`), each pass on the last one's
1671   result, through the feedback stack the simnet uses — the `input` child
1672   reads the pass before. **Stop When Unchanged** ends it at a pass that
1673   changes nothing (`Detail`'s `PartialEq`, every value a reader can see).
1674   No frames, and nothing kept between evaluations. It is enterable, the
1675   scene walk does not recurse into it (one pass drawn beside the result
1676   would be wrong, as for a simnet), and dived in its chain is shown as its
1677   LAST pass saw it.
1678 - **Seed** (`seed`) — inside a loop, what the loop BEGAN from: a repeat's
1679   Input, a simnet's seed (the rest shape, which Relax's Rest and the
1680   remesh's projection want). Elsewhere, the subnet's Input. Plumbing, so it
1681   ignores bypass as `input` and `output` do.
1682 - **Split Edges / Collapse Edges / Flip Edges** (`split_edges`,
1683   `collapse_edges`, `flip_edges`) — one remesh pass each toward a Target
1684   Length (`remesh::edge_pass`).
1685 - **Relax's Tangential mode** — the remesh's relaxation: toward the
1686   neighbours' centroid by Amount, less the normal part, Iterations times
1687   (`remesh::relax_tangential`). Positions only, so polygons and primitive
1688   attributes come through.
1689 - **Project** (`project`) — every point to the nearest place on a Surface
1690   (`remesh::project_onto`; the grid is kept per thread by a hash of the
1691   surface, since the subnet projects onto one surface every pass).
1692 
1693 **The native `remesh` type still evaluates** (`resolve_remesh_geometry_with_errors`):
1694 it is what an older save holds until the load recomposes it, it is what
1695 `mold` calls, and it is what the subnet is held to. `merge_template_defs`
1696 turns every native `remesh` into the subnet on load, keeping id, name,
1697 position, flags and values (`recompose_native_embryo`, which does both
1698 now). The native node's **Split / Collapse / Flip / Project** switches are
1699 not rows of the subnet — the passes are nodes — so one that was off
1700 BYPASSES its node inside (`REMESH_PASS_SWITCHES`).
1701 `a_native_remesh_recomposes_on_load` is the test. The switch was
1702 `result1` for its first day; a Remesh saved then is renamed on load
1703 (`rename_remesh_switch`, `a_remesh_saved_with_result1_is_renamed_on_load`). A saved simnet holding a
1704 remesh changes its JSON by this, so its solve goes on from the frame in
1705 hand under a new key ("An edit is in from the next frame").
1706 
1707 Four changes elsewhere came with it:
1708 
1709 - **A subnet evaluates its Input once per evaluation** (`EvalSim::seeds`,
1710   `level_input`): the first child that reads it fills the slot and the
1711   rest read the slot. Without it the subnet's transfer, reading `input1`
1712   beside the repeat, evaluated everything upstream of a remesh twice — in
1713   a simnet, the detangle. A loop fills the slot as it begins.
1714 - **An `input` resolves its subnet's wire from the subnet's level**, not
1715   from inside: a child that shares the wire's name (the Embryo's `sphere1`
1716   beside an outer `sphere1`) is not what the wire names.
1717 - **A level inside a loop is shown as the loop's last pass saw it**
1718   (`push_loop_feedback`, outermost loop first): the scene walk dived into
1719   a subnet inside a simnet, the spreadsheet and markers
1720   (`node_geometry_as_shown`) and the pull arrows. Until then, dived into a
1721   subnet inside a simnet, its `input` read the simnet's seed and the level
1722   showed one run of the chain from frame 1 while the simnet beside it
1723   played. A visible subnet child of a simnet draws in the simnet's
1724   interior too, as its output.
1725 - **A template child that lists children brings them** (`load_fs_tree`),
1726   rather than its base template's — `repeat1` is a Repeat holding the
1727   passes, not the Repeat template's empty loop.
1728 
1729 `a_repeat_loops_its_chain_and_shows_its_last_pass` covers the loop, the
1730 seed and both interior views.
1731 
1732 ### The wrangle node
1733 
1734 `src/wrangle.rs` is a script run once per element, on Rhai — Phase 7 step 1
1735 of `shapeshifter.md`, and the app's scripting surface for per-element work
1736 where the retired `opencl` node used to be the only one. The engine is a dependency;
1737 what the module owns is the BINDING to the `Detail`, and it is VEX-shaped on
1738 purpose so `@P.y += sin(@P.x) * 0.1;` reads as it does there.
1739 
1740 `@name` is sugar. Rhai has no `@` token, so `desugar` rewrites `@name` into
1741 an index on an element marker (`__at["name"]`) outside strings and
1742 comments, and everything after it — `.x`, `+=`, `[0]` — is Rhai's own syntax
1743 on the value that came back. The indexers reach the geometry through a
1744 shared context; the marker is a VARIABLE in the scope, not a constant,
1745 because Rhai refuses to assign through an indexer on a constant and `@P = …`
1746 is exactly that (the first cut used `push_constant` and every write failed
1747 with "Cannot assign to indexer of constant"). Naming an attribute creates it,
1748 typed by the first value written — a float, an int (a bool is an int), a
1749 `vec3`, an array of two or four — and a write to an existing attribute
1750 converts to ITS type, so `@mass = 2` into a float attribute is `2.0`. A
1751 float2 reads back as a `vec3` with z = 0, a float4 as an array. `@P`, `@Cd`,
1752 `@N` (computed on read when absent), `@id`, `@ptnum` / `@primnum`, `@numpt` /
1753 `@numprim` and `@Frame` are intrinsics; on the Primitives class `@P` is the
1754 centroid and read-only, and on Detail `@name` is a detail attribute.
1755 
1756 **`ch("path")` is resolved BEFORE the run, not called during it.**
1757 `channel_refs` scans the script for the paths it names as string literals,
1758 and the evaluator in `geometry.rs` resolves each through the expression
1759 `TreeScope` — the one scope, so a parameter that is itself an expression is
1760 evaluated first and the script sees its value; that is the seam Phase 7's
1761 step 2 names, and neither language knows the other exists. Two things follow:
1762 `ch` costs a map lookup per element rather than a tree walk, and a path built
1763 at runtime is an error that says why. `chs` reads text, `chv` a float3, `chi`
1764 truncates.
1765 
1766 `neighbours(pt)`, `prims(pt)`, `points(prim)` read the derived topology and
1767 `nearest(pos, r)` the point grid — built once at the first call from the
1768 positions as they then stand, and keyed by radius. `point(name, i)` /
1769 `setpoint`, `prim` / `setprim`, `detail` / `setdetail`, `ingroup` /
1770 `setgroup` reach elements other than the current one. `addpoint`, `addprim`
1771 and `removepoint` are DEFERRED and applied after the run, so a script
1772 iterating points sees a stable count; `addpoint` returns the index the point
1773 will have, which is what makes `addprim([a, b, c])` in Detail class a way to
1774 build geometry from no input at all — a wrangle with nothing wired still runs.
1775 
1776 Ints and floats mix (`@P.y * 2` works), which Rhai does not do on its own;
1777 the mixed arithmetic and comparison operators are registered by hand, as are
1778 `vec3`'s. Two budgets: `OPS_PER_ELEMENT` operations per element, which is
1779 the retired `kernel_cpu`'s step budget as a setting rather than a hand-rolled counter,
1780 and `RUN_BUDGET` seconds of wall clock for the whole run, checked in
1781 `on_progress` every few thousand operations. Any failure — syntax, a runtime
1782 error on an element, a budget — fails the WHOLE run, named by node and
1783 element (`wrangle1: point 4: …`), and the input passes through untouched: a
1784 half-wrangled geometry is not a result. Compiled scripts cache by desugared
1785 source in a thread-local, as the retired launcher cached kernels.
1786 
1787 CPU only, deliberately: an interpreter is an order of magnitude or more
1788 below native Rust, which is fine for tens of thousands of elements per edit
1789 and wrong for a solver at a million per frame. That is Phase 7's step 4
1790 (WGSL compute through the renderer), not a reason to grow this.
1791 
1792 **The Code row applies on ctrl+enter, Escape or leaving the row — never per
1793 keystroke.** cce-ui's `ParametersBg` code editor (line numbers, selection,
1794 clipboard, tab indenting, auto-indent, undo) keeps edits in its buffer
1795 until one of those, because this node evaluates on every value change and
1796 a half-typed line would fail on every keystroke — the border is amber
1797 while edits are pending. **A script error's line is flagged in the row**:
1798 `code_error_line_for_pane` in `render.rs` reads the `(line N` out of the
1799 evaluation error when the node it names is the one the pane shows, and
1800 hands it to `set_code_error_line`; Rhai's line numbers survive `desugar`
1801 because the `@` rewrite never adds or removes a line. Cleared on the next
1802 evaluation that says nothing about that node.
1803 
1804 ### GPU compute: the springs solve is the first operator (Phase 7 step 4)
1805 
1806 `src/gpu.rs` keeps one `cce_ui::vk::ComputeDevice` per thread, opened on
1807 first use and kept, so the pipeline cache and the buffers survive from one
1808 edit to the next; a device costs tens of milliseconds to open and a kernel
1809 a few to compile, and an operator that paid both per evaluation would lose
1810 to the CPU every time. `CCE_COMPUTE` decides: unset or `auto` takes the GPU
1811 when there is one and the operator judges the input big enough; `cpu`
1812 never opens a device; `gpu` insists, and an operator that cannot get one
1813 says so through the node-error slot rather than silently taking the CPU
1814 path. **Under `cfg(test)` auto means CPU**, so the suite is the same on
1815 every machine and the GPU is exercised only by the tests that ask for it
1816 by name — the cross-checks. The suite never SETS the variable: libtest
1817 runs tests in parallel and one that did would race every other test
1818 reading it (`gpu::parse` is the pure function the choice test covers).
1819 
1820 `src/springs.rs` is the pattern every later operator follows: one
1821 algorithm, one data layout, two backends held to each other by a
1822 cross-check (`springs_gpu_matches_cpu`, agreement to 1e-4 over 1.5k
1823 points; it skips with a note where there is no Vulkan). The layout is the
1824 GPU's — positions as a flat `xyz` array because a `vec3<f32>` in a WGSL
1825 storage array pads to 16 bytes, the rest topology as CSR with the rest
1826 length on each incident entry, pins as one `u32` per point — and the CPU
1827 walks the same arrays in the same order. `solve` chooses the backend; a
1828 GPU failure in auto mode falls back to the CPU with one stderr note.
1829 
1830 **Relax's Springs mode is a JACOBI solve now.** Until 2026-09-24 it was
1831 Gauss–Seidel over the edge list in sequence, every correction visible to
1832 the next edge, which no per-point kernel can reproduce; rather than let a
1833 GPU Jacobi and a CPU Gauss–Seidel drift apart, both run Jacobi: each point
1834 gathers the corrections of its incident edges from the pass's starting
1835 positions — half the error toward a free neighbour, all of it toward a
1836 pinned one — averages them, and moves once. It converges roughly half as
1837 fast per iteration, which Iterations already controls; the pinned-pull
1838 test that defines the node's behaviour passes unchanged.
1839 
1840 **The whole solve is ONE submission** (`run_passes_over` with a ping-pong
1841 pair): the topology goes up once, the passes are chained by memory
1842 barriers with the positions alternating between two device buffers, and
1843 the result comes back once. The first cut submitted a pass at a time and
1844 LOST to the CPU at every size measured, 134k points included — a
1845 submission's round trip is about half a millisecond on an integrated GPU
1846 whatever the dispatch inside it, and sixteen of them buried a solve that
1847 takes microseconds. `springs_timing` (ignored; run in release with
1848 `--ignored --nocapture`) is the measurement, on an Intel Iris Xe, sixteen
1849 passes: 1.5k points cpu 0.25 ms / gpu 1.5 ms; 15k cpu 2.6 ms / gpu 3.7 ms;
1850 135k cpu 25 ms / gpu 14 ms, plus ~15 ms of pipeline compile on a device's
1851 first run. `GPU_MIN_POINTS` (32k) is the auto threshold that follows: the
1852 GPU is a win for large meshes and a loss for the ones most projects have,
1853 which is the honest state of step 4 and the reason auto does not simply
1854 mean GPU.
1855 
1856 **Collision is the second operator (`src/collide.rs`), and the one the
1857 GPU is made for.** The node's test has always been a brute-force loop —
1858 every query against every collider triangle, the Voronoi-region distance
1859 for Proximity and a Möller–Trumbore parity cast for Inside — so the work
1860 is queries x triangles, per-point, one dispatch, no passes to chain. The
1861 resolver now runs the test as ONE batch over every element the type asks
1862 about (points, or primitive centroids), where it used to hand
1863 `select_elements` a closure that asked one point at a time; that batch is
1864 what can go to the GPU whole. Same algorithm step for step on both sides,
1865 held by `collision_gpu_matches_cpu` (zero disagreements over 6k queries x
1866 1.7k triangles in both modes; a knife-edge query at the threshold may
1867 round either way and is tolerated only there). `collision_timing` in
1868 release, Proximity: 3.6M pairs cpu 20 ms / gpu 2.2 ms; 15M pairs cpu 76
1869 ms / gpu 6.6 ms; 242M pairs cpu 1150 ms / gpu 52 ms. `GPU_MIN_WORK`
1870 (250k pairs) is the auto threshold.
1871 
1872 **Two per-point operators are deliberately NOT on the GPU, and the
1873 measurements above say why.** Neighbour's Diffuse and Concentrate are a
1874 single gather per evaluation — one pass, then the rest of the graph runs
1875 on the CPU before the next frame's pass — so there is nothing to chain
1876 into one submission, and a single pass costs ~0.5 ms of round trip against
1877 a CPU gather that takes less than that on any mesh a project has. Relax's
1878 Repel rebuilds a spatial grid every pass, which is the part that does not
1879 fit a chained submission; a GPU-side grid is a project of its own, and a
1880 brute-force O(n^2) pass that the CPU twin would then have to match is a
1881 regression for every CPU user. Both stay native until a workload asks.
1882 
1883 ### The Detangle solve
1884 
1885 `geometry::apply_detangle` is the node and says what it does; `src/detangle.rs`
1886 is how it is run. The algorithm did not change on 2026-09-29, what it
1887 costs did: measured on a 162-point simnet of pull, relax and detangle, the
1888 node was nine tenths of the solve (0.35 ms a step against 0.03 for the
1889 rest), and four things it paid for every step were things a step does not
1890 need.
1891 
1892 - **The topology is built once.** The edge list and each point's excluded
1893   rings are connectivity, which this chain never changes — but every step
1894   arrives as a fresh `Detail`, whose derived topology is deliberately not
1895   cloned. They are kept per thread by a hash of the primitives (`Topo`,
1896   the last four), so a solve of a hundred steps builds them on the first.
1897 - **A pass that separates nothing ends the solve.** The passes gather
1898   against the positions at their start, so the next would find the same.
1899   A surface that touches itself nowhere is one pass, whatever Iterations
1900   says.
1901 - **The grid is reused between passes** while no point has drifted more
1902   than half a cell from where it was filed (`DRIFT_CELLS`); the search
1903   reaches a drift further than the thickness, so a moved point is still
1904   found. It is flat — one array sorted by cell — where `spatial::PointGrid`
1905   is a vector per cell and allocated a scratch list per query.
1906 - **A point outside the Group is not searched for.** Its push was worked
1907   out and thrown away.
1908 
1909 **The results are the first version's bit for bit** — the same pairs,
1910 summed in the same order (candidates ascending, as `PointGrid` sorted
1911 them) — which is what makes these optimizations and not changes. The
1912 first version is kept as `apply_detangle_reference` under `cfg(test)`,
1913 and `the_detangle_solve_matches_its_reference` runs both step after step
1914 over meshes that tangle and ones that do not;
1915 `the_detangle_solve_skips_what_it_does_not_need` reads `detangle::Work`
1916 for each saving. On the project it was measured on, detangle's share of a
1917 solve to frame 30 went from 10 ms to 2, to frame 120 from 46 to 15, and
1918 to frame 240 from 113 to 71: once the whole surface is within a
1919 thickness of itself every pass runs and the pairs themselves are the
1920 work, and no bookkeeping saves that.
1921 
1922 **The Surface method and the measure (2026-09-29).** Everything above is
1923 the node's `Points` method, which is what a node without a `Method` row
1924 runs and what the template defaults to, so a save from before solves as it
1925 did. `Method: Surface` (`detangle::solve_surface`) tests each point against
1926 the TRIANGLES near it: a point over the middle of a triangle is near no
1927 corner of it, so where triangles are larger than the thickness the point
1928 test sees nothing at all. A contact is resolved along the line from the
1929 closest point on the triangle to the point (the triangle's normal where the
1930 point lies on it), and the move is SHARED — the point one way, the corners
1931 the other by how much of the closest point each is
1932 (`spatial::closest_weights_on_triangle`), with what is outside the Group
1933 taking none and the rest all of it, so a contact with a fixed triangle is
1934 resolved whole where Points resolves half. What a point receives from
1935 several contacts is their average weighted by depth, not their sum: a point
1936 over a shared edge touches both triangles and must move once. A triangle
1937 with a corner inside the point's excluded rings is not a contact.
1938 
1939 **Inside a simnet a point has a SIDE** (`detangle::apply_from`, the same
1940 day). `resolve_detangle_geometry_with_errors` hands the solve the state the
1941 substep consumed — the nearest simnet above the node that has pushed one,
1942 so a detangle in a subnet inside a simnet gets it too — and a `before` that
1943 is not this mesh (another point count, other primitives) is not used.
1944 Outside a simnet there is none, and Surface is the distance test alone.
1945 Three things read it:
1946 
1947 - **The passes put back what went through.** `went_through` asks in the
1948   TRIANGLE'S terms, since both move: the point's height over it and the
1949   place of its foot in it, then and now, a straight line between. A sign
1950   change with the foot inside is a passage, and the contact is resolved
1951   along the triangle's normal to a thickness clear on the side the point
1952   came from. A point with such a contact takes no other that pass — the
1953   triangles beside the one it went through see it near, on the wrong side,
1954   and would push it on. The passes take NO margin on "inside": a tenth of
1955   one pushed points off triangles they had gone around and left more
1956   crossed than no memory at all (642 points, Thickness 0.5: 40 beyond the
1957   rings against 0).
1958 - **The hold.** Whatever is still through a triangle when the passes are
1959   done goes back to where the step began, its triangle's corners with it
1960   (`HOLD_ROUNDS` looks, a margin of `HOLD_MARGIN`, since holding a point
1961   that did not quite go through costs it a step's movement and nothing
1962   else). The memory is one step long — a point left through is, to the
1963   next step, a point that began there — so this is what keeps a miss from
1964   becoming permanent.
1965 - **Step Limit** (a row, shown for Surface, in thicknesses, 0 = off and
1966   the default) cuts each movable point's move since the step began to that
1967   length before anything is resolved. Off by default because it changes
1968   how far a pull pulls, and because the measurements did not earn it a
1969   default: it is for use WITH Substeps.
1970 
1971 `the_surface_method_puts_back_what_went_through` carries a patch through a
1972 fixed sheet in one step, by less than a thickness and by several, and
1973 around its edge; `the_step_limit_holds_a_step_to_a_length` and
1974 `a_detangle_in_a_simnet_knows_where_the_step_began` (in `geometry.rs`'s
1975 tests, where the feedback stack can be reached) are the other two.
1976 
1977 **Edge Contact** (a toggle, shown for Surface, on in the template; a node
1978 without the row is one from before it and reads off). Two edges can pass
1979 through each other with no point of either going through any triangle:
1980 on the sphere test at Thickness 1 the solve above put nothing back and
1981 held nothing, and still left 48 crossings beyond the rings at half an edge
1982 a step and 98 at four fifths. With it on, every side of every triangle
1983 (`Topo::sides` — the mesh's edges and the diagonals a fan cuts across a
1984 quad) is tested against the sides near it that share no neighbourhood
1985 with it, a pair met once from the earlier of the two. Where the two are
1986 nearest at a place INSIDE both (`nearest_on_segments`; an end is a point,
1987 and the point test has it) they are parted along the line between those
1988 places, the four ends sharing the move by how near each is. Told where the
1989 step began, two edges that went through each other (`edges_went_through`,
1990 `went_through`'s question asked of two lines, and refused where the two
1991 have swung past running the same way, which turns their own direction
1992 over) are put back, and held if the passes leave them through. A contact
1993 is one shape for both tests — four points, a share each, a direction — so
1994 the averaging and the group rule are written once.
1995 
1996 **Which edges are tested is a bound, not a guess.** Two edges nearer than
1997 `close` somewhere along them have an end within that and half the edge's
1998 length of the other edge, which is a side of a triangle; so the point loop
1999 looks that far (`close + half[p]`, half the longest side at the point) for
2000 a triangle with at least two corners outside the point's rings, and an
2001 edge with neither end near one is skipped. The first cut flagged a point
2002 only when a triangle was within a THICKNESS of it, which cost nothing and
2003 kept every number on the sphere test — and skipped the one case only an
2004 edge test can see, two edges meeting at right angles with every point far
2005 from the other triangle. `edge_contact_parts_edges_no_point_test_can_see`
2006 is that case, near and carried through.
2007 
2008 What it costs, 2562 points, release: nothing is searched on a round
2009 sphere at Thickness 0.5 (5.0 ms against 2.7, the wider look), a quarter of
2010 the edges at Thickness 1 (11 ms against 5); on the sphere test, where most
2011 of the mesh is in contact, 129 ms a step against 24. What it buys there:
2012 NO crossing beyond the rings in any of the twelve runs (three sizes, two
2013 paces, two thicknesses), where the side alone left up to 140. What it does
2014 not: folds inside the excluded rings, which the measure still counts and
2015 which in the thin, fast runs were MORE with it on (545 against 358 at
2016 2562 points, Thickness 0.5, four fifths of an edge a step).
2017 
2018 **Fold Contact** (a toggle, shown for Surface, on in the template, inside a
2019 simnet only; a node without the row reads off). The rings are excluded
2020 from contact because a neighbour is nearer than a thickness by
2021 construction, and no distance says whether it is too near — so a surface
2022 folding through its own neighbourhood was seen by nothing, and on the
2023 sphere test everything the other rows left was that. But going THROUGH is
2024 not a distance. `detangle::folded` asks the through question of every pair
2025 inside each other's rings that shares no point: a point and the triangles
2026 at the points of its rings, a side and the sides at the points of its
2027 ends' rings (sides only with Edge Contact on). It walks the MESH
2028 (`Topo::tris_at` / `sides_at`), not the grid: what is in a point's rings is
2029 in them however far apart the fold has left them. It runs once when the
2030 solve begins, and what it finds is a contact in every pass — put back as
2031 far over its neighbour as it BEGAN (`height.min(thickness)`), not out to a
2032 thickness, which a neighbour never was — and again in each look of the
2033 hold, which returns what is still folded to where the step began.
2034 
2035 **When something went through is searched for, not read off**
2036 (`crossing_time`). The first `went_through` took the moment and the place
2037 from a straight line between the two ends' heights and weights, which is
2038 right for a small step and wrong for a long one: a point carried across
2039 several triangles was said to have gone around the one it went through,
2040 and two edges were put back on the wrong side of each other. Both tests
2041 now take the volume the four points span, which changes sign when they
2042 are in one plane, find that moment by halving, and ask where the foot (or
2043 the lines' meeting) was THEN. `fold_contact_puts_back_what_went_through_its_own_neighbourhood`
2044 is the fixture that showed it.
2045 
2046 With all three on the sphere test ends with NO crossing of any kind, at
2047 any step, in all twelve runs (the `all` row), where the side and the edges
2048 together left up to 604. What that costs: about seven times the Surface
2049 method told the side alone where most of the mesh is in contact (234 ms a
2050 step against 32 at 2562 points, 12 against 2 at 162), most of it the
2051 edges'. And it is bought by HOLDING: some 200 points a step at 2562 are
2052 put back where the step began, a tenth of the mesh, so a fold that is
2053 being forced stops moving there rather than folding. That is the
2054 guarantee working, and it will read as the surface sticking.
2055 
2056 **How the Surface method is run (2026-09-29, later).** The costs quoted
2057 above are from before this and are kept as the record of what each piece
2058 cost when it landed; what it costs now is `detangle_timing` (ignored;
2059 release, `--ignored --nocapture`), the sphere test with every row on: 6 ms
2060 a step at 162 points, 11 at 642, 27 at 2562, where it was 9, 44 and 186 —
2061 and Surface alone 4.6 ms at 2562 where it was 18. The test prints the sum
2062 of every position at every step, and that sum did not move through any of
2063 the three changes (-55113.128580 at 2562 points): they are how the solve
2064 is run and not what it does. Timed split by phase first, which is what
2065 said the cost was not the contacts and not only the edges: every pass, and
2066 every look of the hold, was repeating one spatial search.
2067 
2068 - **What is near what is found once** (`detangle::Near`): the pairs a pass
2069   looks at — a point and a triangle, two sides — are listed when the solve
2070   begins, with half a thickness to spare (`SLACK`), and kept until a point
2071   has moved half of that. The passes and the hold walk the list. On the
2072   sphere test that is 1.0 to 1.3 searches a step where there were five or
2073   more.
2074 - **A pair is listed by its DISTANCE, not its box.** On one sheet the
2075   sides a few edges off are near enough for their boxes and further than
2076   any thickness; listing by box put 197k pairs of sides on the list at
2077   2562 points, by distance 79k. What may have gone through since the step
2078   began is within twice what anything has travelled of what it went
2079   through, so that, or the thickness, is how far a pair may be.
2080 - **The hold looks again only at what it put back** (`stirred`): a pair
2081   none of whose points moved since it was last looked at is as it was.
2082   The first look is at everything.
2083 - **The work is cut into pieces and run on every core** (`in_pieces`):
2084   the search, the fold sweep, the pairs of a pass, the pairs of a look.
2085   Each piece makes its own list and the lists are put end to end in order,
2086   so the result is what one thread would have made, contact for contact —
2087   which is why the sum holds. `std::thread::scope`, not a pool: the crate
2088   has none, a thread costs tens of microseconds to start, and each call
2089   names the least a piece may be so that small meshes stay on one thread.
2090   Most of the gain is this, and it is the machine's: on the twenty threads
2091   it was measured on, 149 ms became 27; the first three changes alone took
2092   186 to 149.
2093 
2094 **On a project** (`detangle_on_a_project`, ignored; release, `--ignored
2095 --nocapture`, with `CCE_DETANGLE_PROJECT` naming a project directory or its
2096 state.json, `CCE_DETANGLE_FRAMES` how far to play, 240, and
2097 `CCE_DETANGLE_OUT` a directory for each way's last frame as an OBJ). The
2098 file is read and never written: the detangle node inside its simnet is set
2099 each way in turn in memory and the simnet played forward a frame at a
2100 time on one cache, as playback does. It is how the node was first run on
2101 something that was not a test's fixture — a 162-point icosphere with one
2102 point pulled through its own far wall, relaxed behind it: as saved
2103 (Points) the first crossing is at frame 20 and 35 edges are through a
2104 triangle at frame 240, the pulled point a spike out of the far side;
2105 Surface alone 71, later (frame 81); with Fold Contact 9; with everything
2106 on none at any frame, the far wall carried out ahead of the point as a
2107 tent, at 5.1 ms a frame against 0.5. Every row mattered there: the edges
2108 alone and the folds alone each left crossings.
2109 
2110 `detangle::self_intersections` is the MEASURE: every edge passing through a
2111 triangle (`spatial::segment_crosses_triangle`, tolerance relative to the
2112 lengths, so scale does not change the answer), no thickness and no rings.
2113 `crossings_beyond(geom, rings)` counts only those the solve is meant to see
2114 at a ring count, which is what separates a miss of the method from a fold
2115 inside the excluded neighbourhood. The node's **Tangled Group** row, when
2116 it names one, writes the points of what is STILL crossed after the solve
2117 (empty when nothing is); it costs a second search of the mesh and is off
2118 by default. `detangle_methods_compared` (ignored; release, `--ignored
2119 --nocapture`) pushes an icosphere's cap down into its own bowl a fifth of
2120 an edge a step. At 2562 points, Thickness 1, Rings 2: no detangle 1117
2121 crossings, all beyond the rings; Points 1699 (892 beyond), 5.1 ms a step;
2122 Surface 408, NONE beyond the rings, and none left at the end, 17 ms a step
2123 (24 told where the step began: the wider search and the hold's look).
2124 At four fifths of an edge a step and Thickness 0.5, where a step outruns
2125 the thickness, Surface alone let 804 through beyond the rings and the side
2126 brought that to 140 (642 points: 294 to 0).
2127 At Thickness 0.5 Surface let 72 through beyond the rings at that size and
2128 none at 162 and 642 points. What Surface leaves is the fold at the cap's
2129 rim, inside the rings. (Those figures are with Edge Contact off, as the
2130 test's `surface` and `sided` rows are.) Do not measure by pressing a sphere flat by the sign
2131 of y: that carries the equator's points past their own neighbours, which no
2132 setting is meant to see, and both methods look equally bad.
2133 
2134 What still costs is the solver's, not the node's: an edit inside a simnet
2135 re-solves from the seed, so a change at frame 120 is 120 steps. A
2136 backward scrub no longer does — the next section.
2137 
2138 ### Where a simulation's time goes
2139 
2140 `sim_profile_on_a_project` (ignored; release, `--ignored --nocapture`,
2141 with `CCE_SIM_PROJECT` naming a project directory or its state.json and
2142 `CCE_SIM_FRAMES` how far to play, 60) plays the project's simnet forward
2143 as saved and again with each node of its chain bypassed in turn, so what
2144 a node costs is what the solve saves without it. The file is read and
2145 never written, and the disk cache is off for the run. On the project it
2146 was written for (2026-09-29; a pull, a Surface detangle with every row on
2147 and a remesh, 162 points growing to 525): 46 ms a frame as saved, 24
2148 without the detangle, 6 without the remesh. The remesh was most of it
2149 twice over, and three changes the same day brought the solve to 15 ms a
2150 frame, 11 once the mesh is at rest, where what is left is the detangle's
2151 own work at 524 points:
2152 
2153 - **The flip pass keeps a valence table** (`remesh::flip_pass`), counted
2154   once and kept in step with the flips. It had asked `tris_of` for a
2155   point's valence eight times an edge, each a list gathered, sorted and
2156   counted: 9 ms of a remesh at 525 points.
2157 - **The closest-point search begins at a quarter of a cell**
2158   (`TriGrid::closest`) and works a normal out for the winner alone. A
2159   query on the surface, which is what a remesh's projection asks, is
2160   answered from the cell it is in; it was 6 µs a query from the
2161   twenty-seven cells about it, 12 ms a remesh. Where a search begins does
2162   not change what it finds, since it ends only on a hit nearer than the
2163   box searched is wide.
2164 - **A remesh settles.** The flip pass refuses a flip whose new edge the
2165   next split would cut, as the collapse pass always refused its own.
2166   Without the rule a long edge between two thin triangles was split and
2167   its midpoint collapsed into a corner — the edge turned to its short
2168   diagonal — and the flip pass, judging by valence alone, turned it back:
2169   93 edges split, collapsed and flipped at every step of a mesh that had
2170   stopped moving. And an iteration that finds nothing to split, collapse
2171   or flip, with no relaxation asked, ends the remesh; on the first the
2172   INPUT is handed back as it came, primitives and their order untouched,
2173   so the topology the detangle keeps its lists by is the same from one
2174   step to the next. The projection's grid is built when first wanted.
2175 
2176 The first two are how a remesh is run and not what it does: the passes as
2177 first written are kept under `cfg(test)` (`flip_pass_reference`,
2178 `TriGrid::closest_reference`, `remesh_reference`) and
2179 `the_remesh_matches_its_reference` holds the mesh to them bit for bit,
2180 step after step. The third changes what a remesh makes, where a flip
2181 would have made an overlong edge; `a_remesh_settles_and_then_leaves_the_mesh_alone`
2182 is its test, and fails without the rule ("still changing 162 edges after
2183 20 rounds"). `remesh::last_changes` is the count the test reads, and
2184 `remesh::take_edge_changes` the profile's: every edge changed since it was
2185 last taken, by a native remesh or a pass node, since the Remesh subnet is
2186 several passes where `last_changes` sees one remesh.
2187 
2188 ### A grouped point survives a remesh
2189 
2190 Two rules in `remesh::collapse_pass` (2026-09-29), found on the project
2191 above: its pull group lost its one member at frame 16 and the pull went
2192 on with nothing to pull, which is why the simulation "stopped moving by
2193 frame 30" — the remeshed point count held at 524 for the rest of the run.
2194 
2195 - **A collapse that would strand a corner is refused.** The two
2196   triangles on a collapsed edge fold to nothing, and each takes one
2197   triangle from its third corner; a corner left with fewer than three
2198   has no fan to stand in, and one left with none is a point on no
2199   triangle, which `into_detail` drops — identity, values, groups and all.
2200   That is how the pulled point went: at the tip of a spike, its
2201   neighbours collapsing around it, it was never one end of a collapsed
2202   edge itself. (The other rule, `too_long`, is what stops a collapse
2203   undoing a split; this one is the link condition remeshers carry.)
2204 - **The survivor of a collapse is the end in more groups**, the lower
2205   index on a tie as it always was, and it joins the other end's groups:
2206   a point in a group is a point something downstream names.
2207 
2208 `a_grouped_point_survives_a_remesh` pulls a sphere's point out a spike
2209 over forty remeshes and fails without the first rule; the second is not
2210 what the fixture exercises and is kept for the case it describes.
2211 
2212 ### Simulation checkpoints
2213 
2214 A step is not invertible, so going back means going forward from
2215 somewhere earlier, and until 2026-09-29 that somewhere was the seed: a
2216 scrub from frame 120 to 119 was 119 steps, and dragging the playhead
2217 backwards re-solved the whole history at every frame it passed. A solve
2218 now keeps CHECKPOINTS in memory (`geometry::Checkpoint`, on the
2219 `SimSolve` beside the latest state): a frame's state and what its last
2220 substep consumed, which is everything a resume and the interior view
2221 need.
2222 
2223 - **One every `CHECKPOINT_EVERY` frames — every frame since
2224   2026-10-06**, so a frame once solved plays back with no step at all.
2225   It was ten until then, and playing back through what had been solved
2226   (the playbar's cyan) stepped nine frames in ten again, a whole step
2227   each, growing with the mesh: on the user's project at 8k points the
2228   evaluation of a replayed frame was 100 ms and is 1 ms. Kept as the
2229   solve passes
2230   it — and as it LEAVES it, which is the case that is easy to miss:
2231   played a frame at a time the solve is always asked for the very next
2232   frame, so it never passes a frame on the interval, it arrives on one
2233   and leaves from it.
2234 - **The frame a backward scrub leaves is kept too**, so coming forward
2235   to it again is a resume.
2236 - **A resume takes the nearest kept frame at or behind the one asked
2237   for**, the latest state included, so a scrub either way inside what
2238   has been solved steps fewer than an interval's frames.
2239 - **They belong to one key.** An edit to the chain or the seed changes
2240   the key and they go with the solve they were frames of; the frame in
2241   hand does not (the next section).
2242 - **Within a count and a budget** (`CHECKPOINTS_MAX` 1024,
2243   `CHECKPOINT_BUDGET` 4 GB per simnet by an estimate of a state's size;
2244   48 and 512 MB until every frame was kept, then 2 GB until later on
2245   2026-10-07, when the user raised it). **The budget is the SUM of
2246   what the kept checkpoints hold, each at its own size** (since
2247   2026-10-07, `Checkpoints::bytes`); it was the budget over the size of
2248   the checkpoint just kept, which charged every frame of a growing
2249   simulation at the size of the latest. On the user's project, a surface
2250   growing from 162 points to 57k over 240 frames, that thinned the whole
2251   history to every fourth frame while holding about 0.4 GB, and a replay
2252   of the cached frames 120–240 re-solved three in four at 690 ms a frame;
2253   counted by size, all 240 are kept at 1.5 GB (1.37 GB resident in a
2254   shadow session) and the replay runs no step at 34 ms a frame. Each
2255   checkpoint carries its frame's topology since later the same day (see
2256   "A copy of a mesh shares its topology"), which brings the 240 to 1.9 GB,
2257   close enough to the 2 GB budget then that the user raised it to 4.
2258   `checkpoints_are_budgeted_by_what_each_holds`. With no
2259   room the SPACING doubles and stays doubled — what is off the wider
2260   interval goes, and what arrives after arrives that far apart. Not the
2261   oldest: a scrub is as likely to land near the start. And not every
2262   other one while new ones arrive at the old spacing, which is what the
2263   first cut did and which thinned the start of a long solve again and
2264   again until it had gaps of hundreds of frames.
2265 
2266 What a resume arrives at is what a solve from the seed arrives at, state
2267 and feedback both: `a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state`
2268 compares them frame by frame and counts the steps each cost
2269 (`SimCache::steps_run`). **Every evaluation goes through the one cache**
2270 (`State::sim_cache`): the scene rebuild, the pull arrows, the params
2271 pane's pickers, and since 2026-09-29 the spreadsheet and the
2272 selected-group markers, which `sync_nodes` evaluates against a node
2273 borrowed off `State` and which each used a throwaway cache for that
2274 reason — a full solve from the seed at every refresh of either. The
2275 cache is taken out before that borrow begins and put back after it, as
2276 the scene rebuild takes it.
2277 `the_spreadsheet_and_group_markers_share_the_sim_cache` counts the steps.
2278 
2279 **The spreadsheet and the group markers follow the frame and upstream
2280 edits** (`State::sync_selection_readouts`, since 2026-09-29). Their keys
2281 are the selected node, its parameters, the geometry version and the
2282 frame; they run from `sync_nodes`, from the end of every scene rebuild —
2283 as the pull arrows do — and from the tick's frame change when the graph
2284 holds no simnet and so nothing rebuilds. The spreadsheet's key had been
2285 the node and its OWN parameters, and both ran from `sync_nodes` alone,
2286 which a frame change does not call: during playback the spreadsheet
2287 showed the frame it had been opened on, and an edit upstream of the
2288 selection left it showing the values from before. A refresh keeps the
2289 spreadsheet's scroll and sort. On the project this was measured on, a scrub
2290 back over sixty frames from frame 120 went from a mean of 15 ms a frame
2291 to 1.3, and from frame 240 from 67 to 4. The disk cache (`Cache` on the
2292 simnet) still holds one frame: the last one SOLVED. It is written only
2293 when an evaluation steps (since 2026-10-07): until then every evaluation
2294 wrote it, a resume from memory included, and since the scene, the
2295 spreadsheet and the pull arrows each evaluate the simnet every frame, a
2296 Cache-on simulation wrote its whole state to disk several times a frame
2297 even while replaying frames solved long before — 4 ms of each of those
2298 evaluations at 11k points. `a_cached_simnet_writes_to_disk_only_when_it_solves`.
2299 
2300 ### Selected spreadsheet rows are marked in the scene
2301 
2302 A row of the spreadsheet is a POINT of the node it shows, by index, and
2303 rows can be selected (since 2026-09-29; the selection itself is cce-ui's,
2304 see its CLAUDE.md, "A spreadsheet's rows can be selected"): a press
2305 selects one, ctrl toggles one, shift extends a run. Each selected row's
2306 point wears a marker in the viewport, in the highlight colour the row
2307 wears and at Group Marker Size.
2308 
2309 - **Nothing is evaluated by a selection.** `State::spreadsheet_points` is
2310   where the rows' points were when the table was last filled, kept from
2311   that evaluation; `rebuild_row_markers` builds the markers from it,
2312   and runs from the press, from every refill of the table and from a
2313   change of Group Marker Size.
2314 - **The selection stands across a frame and an edit**, since the rows are
2315   still the points they were, and the markers follow the points. It goes
2316   when the table becomes ANOTHER node's (`sync_selection_readouts`
2317   compares the node id), whose row 3 is some other point.
2318 - **The markers draw only while the spreadsheet is shown**, with the
2319   group markers, before the geometry and at full opacity.
2320 - A detached spreadsheet window has no viewport, and the selection does
2321   not ride the sync channel: rows selected there mark nothing in the main
2322   window.
2323 
2324 `selected_spreadsheet_rows_are_marked_in_the_scene` drives it by pointer.
2325 
2326 **The point groups are the spreadsheet's first columns** after the
2327 point's number (`geometry_to_spreadsheet_columns`, since 2026-09-29):
2328 `g:<name>`, 1 for a member and 0 for the rest. They were `g:` columns
2329 after every attribute, the thirteenth column of a sphere's table and off
2330 the right of any pane, and blank for a point outside the group — so a
2331 group of one point among five hundred was a column that looked empty.
2332 Sorting the column descending brings the members to the top. Only POINT
2333 groups: a row is a point, and the table has no primitive rows. The header
2334 read `group:<name>` until 2026-10-07: since the columns are as wide as
2335 their content (cce-ui's CLAUDE.md, "A spreadsheet's columns are as wide as
2336 their content"), the header is what sizes a column of 0s and 1s, and `g:`
2337 matches the detail columns' `d:`.
2338 
2339 **What reads a selected node for display reads it as the scene shows it**
2340 (`geometry::node_geometry_as_shown`, the same day): the spreadsheet's
2341 rows, the markers on them and the selected group's. A node inside a
2342 simnet is evaluated as the frame's last substep saw it, the feedback of
2343 the nearest simnet above it pushed — the rule the dived-in scene walk and
2344 the pull arrows already drew by. Until then these evaluated the node
2345 bare, so inside a simnet `input` read the seed and the rows, and a
2346 selected row's marker, stood at the first frame while the scene beside
2347 them played. `rows_selected_inside_a_simnet_follow_the_simulation` is the
2348 test.
2349 
2350 **The spreadsheet's scrollbars are a cross behind its plate** (since
2351 2026-10-06; cce-ui's CLAUDE.md, "A spreadsheet's scrollbars are a cross
2352 behind its plate"): the vertical bar down the pane's centre line, the
2353 horizontal one across the body's, idling behind the frosted plate until a
2354 scroll raises them, held in front while the pointer is on a raised one.
2355 It is the params pane's straddle and the dialog's bar, which ride their
2356 centre lines the same way. The render arm draws the idle copy before the
2357 plate (`Spreadsheet::paint_scrollbars`, through `WidgetSlots::spreadsheet`);
2358 the widget paints the fore copy itself. A press on a sunk bar's lane is a
2359 press on the row under it. Checked in a shadow session: sunk, raised by a
2360 scroll, held by hover past the hold, sunk again once the pointer left.
2361 
2362 **The params pane's bar fades too** (the same day; cce-ui's CLAUDE.md,
2363 "Every scrollbar rides a centre line, behind the plate", is the rule all
2364 of these follow). The PARAM_IDX arm draws the idle copy of
2365 `ParametersBg::scrollbar_quads` before the plate every frame and the fore
2366 copy after the rows at `scrollbar_fade()`, where it drew ONE copy on
2367 either side of the plate by the latch, so a raise and a sink were a flip.
2368 Both panes draw the idle copy at `1 - fade` (since 2026-10-06): at full
2369 strength it showed through the plate under a raised bar, and the two
2370 stacked into a bar that read nearly opaque.
2371 
2372 ### An edit is in from the next frame
2373 
2374 An edit inside a simnet's chain, or to its seed, does not restart the
2375 solve (since 2026-09-30; until then the key change dropped the cache
2376 entry, so an edit at frame 120 was a re-solve of 120 frames, and a slider
2377 dragged inside a simnet re-solved the whole run per pixel). In
2378 `resolve_simnet_geometry_with_errors` an entry of another key whose
2379 frame is at or behind the one asked for is kept under the NEW key — its
2380 state and what its last substep consumed — and its checkpoints dropped,
2381 being frames of the solve as it was. So:
2382 
2383 - the frame in hand stands as it is, and the edit shows from the next
2384   frame forward, the solve going on from the state in hand;
2385 - such a solve is MIXED (`SimSolve::edited_at`, the frame in hand at the
2386   edit): its earlier frames are of
2387   the chain as it was. A scrub BACK from it has no checkpoint to resume
2388   from and does not keep the frame it leaves (which a scrub back from a
2389   clean solve does), so it re-solves from the seed with the edit in from
2390   the first frame — at the start frame the seed itself: going back to
2391   frame 1 is what clears the frames solved before the edit, and the solve
2392   that begins there is clean;
2393 - a solve at the start frame (frame 0 of the sim) is always the seed, so
2394   an edit made there restarts at once.
2395 
2396 The disk cache (Cache on) is by key, so a continued solve is written
2397 under the new key and read back by it.
2398 `test_editing_the_chain_invalidates_the_cache` and the edit half of
2399 `a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state` are
2400 the tests.
2401 
2402 ### The volume representation
2403 
2404 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
2405 dims, data }` — with two nodes on it: `volume` (offset and shell) and
2406 `boolean` (union, intersect, subtract). It exists because shelling, offsetting
2407 and booleans are not mesh operations. Doing them on triangles means answering
2408 "which side of this whole surface is that point on" per triangle pair; doing
2409 them on a field means `min`, `max` and a sign flip, and the mesh comes back out
2410 by extraction.
2411 
2412 **Signing the field is the whole difficulty**, and it is done in two parts
2413 because neither part is right everywhere:
2414 
2415 - **Far from the surface**, a flood fill from the grid boundary — which is
2416   outside by construction — marks everything it can reach. Whatever it cannot
2417   reach without crossing the surface is enclosed, however convoluted the
2418   cavity. The flood may only step between samples that are *both* further than
2419   `voxel * 1.01` from any surface, because two samples one voxel apart cannot
2420   both be more than a voxel from a surface lying between them. A looser band
2421   (0.75 voxel was the first try) lets the flood walk straight through a thin
2422   wall and the solid comes back hollow.
2423 - **Inside that band**, the flood has nothing to say, so the nearest face's
2424   normal decides. That test trusts the winding, so the winding is *measured*
2425   first — the signed volume by the divergence theorem, positive when faces look
2426   outward — and the test flips if the mesh is inside out. An imported mesh is
2427   not obliged to agree with this app's convention, and one that disagrees used
2428   to come back with its band signs alternating against the flood's.
2429 
2430 Ray parity was the first approach and is wrong: a ray through a shared edge
2431 crosses two triangles at one point and counts two, so the parity inverts for
2432 every sample behind it. It failed on 79 of 15625 samples in contiguous runs,
2433 which is what a parity bug looks like.
2434 
2435 Extraction is naive **surface nets** (`to_mesh`): one vertex per cell that has
2436 a sign change, placed at the average of its edge crossings, and one quad per
2437 crossed grid edge joining the four cells around it. Chosen over marching cubes
2438 because it produces quads on a quad grid and far fewer degenerate slivers.
2439 
2440 One vertex per cell is also its limit. Where a feature is thinner than a voxel
2441 — the knife-edge rim of a subtraction — two sheets of surface share one cell's
2442 vertex and pinch, leaving edges with four faces. The result is still
2443 watertight; it is not manifold. Hence two predicates on `Detail`, and the
2444 difference matters: [`is_closed`](src/detail.rs) asks that every directed edge
2445 have exactly one opposite (no boundary, consistently wound — what having an
2446 inside requires, and what `Volume::build` guards its input with), while
2447 `is_manifold` asks for exactly two faces per edge (what remeshing requires,
2448 since an edge with four faces has no single pair to flip between).
2449 
2450 `Volume::build` takes an explicit `reach`: distances are clamped there, so the
2451 field is exact near the surface and flat far from it. A boolean builds both
2452 operands on ONE grid so the two fields line up sample for sample.
2453 
2454 ### The 2D page context
2455 
2456 `src/page.rs` is a second context, not a second kind of geometry node. Its
2457 currency is a `Page` — an image: a physical size, a DPI, and straight-alpha
2458 RGBA pixels — its origin is the top-left corner with y running DOWN, and
2459 nothing in it has a point id, an attribute or a normal. Five nodes compose
2460 one: `page` (the generator: preset or custom size, units, orientation,
2461 resolution, colour, opacity, position), `page_grid`, `page_border`,
2462 `page_text` and `page_shape`.
2463 
2464 **The generator's size is in pixels or in real units** (since 2026-09-29).
2465 The `page` node's `Units` row — Inches, Millimetres, Centimetres, Pixels —
2466 is what its Width and Height are written in, and what EVERY node downstream
2467 is written in: the page carries its `PageUnit`, and the resolver converts
2468 each length through `Page::len` before it draws. A property of the page
2469 and not of each node, because a chain whose text was placed in pixels and
2470 whose border was inset in inches is a chain nobody can read. Inside, a page
2471 is still inches (`Page::size`), and a pixel image's physical size is its
2472 pixels over its Resolution. A node with no Units row is in inches, which is
2473 every save from before it. The length rows are `float` — a
2474 number with no range — where they were sliders over a range in inches: a
2475 slider clamps, and no one range holds both 0.25 inches and 1920 pixels.
2476 
2477 **The size IS Width and Height, and a preset writes them** (since
2478 2026-09-30). `page_node_frame` reads Width by Height in Units and nothing
2479 else; Preset and Units are rows that SET those two when they are picked
2480 (`page::follow_page_rows`, called by the params pane's write-back and
2481 MCP's `set_param`, and what it overwrites is part of the undo step).
2482 Preset writes its size in the page's Units — the four paper sizes
2483 portrait, the three raster ones (`HD`, `4K`, `Square`) as they lie; Units
2484 converts them, so the sheet keeps its size. There is no `Custom` preset
2485 (until then Width and Height were read only under it, a second way to say
2486 the size that the presets could not share) and no Orientation row: a
2487 landscape sheet is Width and Height typed the other way round. Preset
2488 names what was last picked, and a size typed in afterwards is the size. A
2489 save from before is carried over ONCE in the template merge
2490 (`page::migrate_preset_rows`), recognised by the Width row's `show_when`
2491 still reading `Preset == Custom`, which the merge then replaces: a named
2492 preset is written into Width and Height, a sheet turned as its old
2493 Orientation row said, and a Custom page keeps its size and names Letter.
2494 The Orientation row is dropped from every page. `picking_a_page_preset_writes_its_size`
2495 is the test.
2496 
2497 **`page_shape`** draws a rectangle (with a corner radius), an ellipse, a
2498 line or a polygon of N sides, turned by Rotation, filled and stroked, each
2499 with an opacity. Coverage comes from the signed DISTANCE to the outline in
2500 pixels (`Page::shape`), so a turned edge and a circle's are clean lines; the
2501 stroke is centred on the outline. A line is its stroke: as long as its
2502 Width, as thick as its Stroke Width.
2503 
2504 **The viewport shows the image, standing in the scene** (since
2505 2026-09-29). The displayed page is uploaded as a texture and staged as a
2506 `cce_ui::vk::SceneImage` — a textured quad in the 3D pass, unlit, depth
2507 tested against the geometry, seen from both sides — in the XY plane about
2508 the page node's `Position`, facing +Z, at its PHYSICAL size: the World Unit
2509 says what one world unit is, and a sheet 215.9 mm wide is 215.9 of them
2510 when that is a millimetre (`PageShown::world_size`, the one place a length
2511 is converted INTO world units). It is staged after the furniture and the
2512 markers and before the geometry, whose fill may be translucent over it, and
2513 the shader discards a texel that shows nothing so a transparent page does
2514 not hide what is behind it. The image is uploaded MIPMAPPED
2515 (`cce_ui::vk::upload_rgba_mipmapped`, since 2026-09-29) and sampled with
2516 anisotropy where the device has it: a Letter sheet at 300 DPI is drawn at
2517 about a fifth of its size in an ordinary pane, and without a mip chain a
2518 ruled page was moiré head-on and worse at a slant. `State::page_shown` is what the stage pass
2519 places it by. Until then a pane of its own (`PAGE_IDX`, an `ImageView`)
2520 took the viewport's rect whenever the level held a page, so a picture and
2521 a model could not be seen together.
2522 
2523 **The path tracer draws it too** (since 2026-09-29), in the viewport's
2524 traced mode and in `--thumbnail`. The stage pass hands the tracer the same
2525 upload at the same corners (`set_rt_scene_with_image`, a `cce_ui::vk::
2526 RtImage`), and cce-ui adds the quad to the traced scene as two triangles
2527 under a textured material — so both tiers meet it as any triangle, and a
2528 scene that is an image ALONE is not an empty one, which the tracer used to
2529 skip. Traced, the image is UNLIT, as the raster pass draws it: a ray that
2530 lands on it takes the image's colour as it is and the path ends there, so
2531 the two views show the same picture — and to the rest of the scene the
2532 image is a light of its own colour, which is what a bounce off the
2533 geometry finds there. (For its first hour it was a lit surface, albedo
2534 under the sky, and a white page traced grey; the user's call.) Where it is
2535 clear a ray goes through, by chance in proportion to the alpha. The traced scene's key is the geometry's version,
2536 the image's (`State::page_version`, moved by every recomposition) and the
2537 world unit, which sizes the image. The thumbnail has no 2D pass to share
2538 an upload with and hands over the pixels (`RtImagePixels`); an image alone
2539 is taken square on and fitted edge to edge (`thumbnail::view_of`), since
2540 from the diagonal a picture is a slanted sliver of itself, and one with
2541 geometry is inside the diagonal view's bounds. The denoiser leaves the
2542 image alone: its pixels are marked as the sky's are, since a colour that
2543 is the image's own has no noise to take out and smoothing took its fine
2544 print first.
2545 
2546 **The display flag is exclusive within its CONTEXT**
2547 (`set_child_geometry_visible`): the page nodes and the geometry nodes each
2548 have one, so a level shows one image and one geometry. One flag over both
2549 is what made showing a picture hide the model. A Geometry NODE's flag is
2550 its own and exclusive with nothing (since 2026-10-02): at the root several
2551 objects draw at once.
2552 
2553 **The image commands** (`src/image_tools.rs`, all registry rows):
2554 `frame_image` (Ctrl+Shift+F, and a viewport-menu row while an image shows)
2555 turns the active camera square to the image and fits it to the pane;
2556 `view_image_pixels` does the same at the distance where one image pixel
2557 covers one display pixel. A plane square to the view axis is scaled by a
2558 perspective and not distorted, so head-on the image is exact. The Default
2559 Camera is turned by setting its orbit to what cancels its base ray's own
2560 yaw and pitch; a camera node has Position, Pivot and Rotation rewritten,
2561 the Rotation taking up whatever orbit the viewport widget holds, which
2562 `get_matrices` applies to every camera. Frame All holds the image's
2563 corners beside the geometry. `new_image` adds a page node, shown and
2564 selected; `add_image_rectangle` / `_ellipse` / `_line` / `_polygon` /
2565 `_text` add a shape or text node wired after the selected image node (else
2566 the shown one, else a new image), placed at the image's middle and sized
2567 from it IN THE IMAGE'S UNIT, shown and selected. Added to the middle of a
2568 chain the node is inserted: what read the target reads the new node.
2569 
2570 **Shapes and text are placed by their handles** (`src/image_handles.rs`,
2571 since 2026-09-29): the third and fourth `HandleSource`s, entered by Edit
2572 Handles like the others and straight away by the `add_image_*` commands.
2573 A shape has three — **move** (its middle), **size** (a corner of its box,
2574 which grows about the middle) and **turn** (the middle of its right edge,
2575 whose direction from the middle is the Rotation) — a line two, its middle
2576 and an end that sets length and angle together; text has its anchor and a
2577 handle one Size under it. They needed three things of the framework,
2578 which the viewer-state section below describes: a `HandleCtx`, the
2579 `drag` hook and the `plane`. The rows are written in the image's unit
2580 through `PageFrame::row` — whole pixels, thousandths of anything longer.
2581 `page::resolve_frame` is what makes the handles affordable: the page
2582 WITHOUT its pixels, read off the `page` node up the chain, since the
2583 overlay asks on every frame it is drawn and composing a sheet to learn
2584 its size would be a sheet a frame. A drag still recomposes the image on
2585 every motion, as dragging a slider does; `rebuild_page` keeps the GPU
2586 image while the size holds (`update_pixels`), where it used to free and
2587 upload one per rebuild and wait on the device each time.
2588 
2589 The two contexts do not mix, and `is_page_node` is the one place that says so.
2590 A page node contributes nothing to the viewport's geometry and a geometry node
2591 cannot feed a page: page chains resolve through `resolve_page`, never through
2592 `generate_single_node_geometry_with_errors`. `export` is the only node in
2593 both — it passes either through, and what reaches it decides the format, so a
2594 page writes a PNG and geometry writes the mesh format its Format parameter
2595 names. There is no PNG option on that parameter, because offering one for a
2596 mesh would be a lie.
2597 
2598 **Resolution is a property of the page, not of the export.** The raster is
2599 size × DPI, and `write_png` puts that in the pHYs chunk, so a printer lays the
2600 file out at the size it was composed at instead of guessing 96. pHYs is pixels
2601 per metre — the only unit PNG offers — so the DPI round-trips through a
2602 conversion and comes back a hair off (300 stores as 11811 px/m, reads as
2603 299.9994).
2604 
2605 Rect coverage is exact area, not a test of the pixel centre. A printed grid is
2606 mostly hairlines, and a binary fill snaps every rule to whole pixels, so a
2607 ruled sheet comes out with lines alternating between one and two pixels wide
2608 down its length — which reads as a wobble in the paper rather than as
2609 aliasing. Grid rules are centred ON their coordinate so a second grid at twice
2610 the cell size lands exactly on the first's, which is the only reason to draw
2611 two. Text shapes and rasterizes through cosmic-text, the toolkit's own font
2612 stack, with system fonts loaded because a page names its font by family.
2613 
2614 The GPU image is owned by `State::page_image` and freed when replaced.
2615 **A replacement renderer invalidates that id.** There is no reconnect
2616 callback: the runner calls `renderer_init` once per renderer, so the first call
2617 is this process's own and every later one is a replacement — remembering is the
2618 only way to tell them apart (`State::seen_renderer`, via
2619 `renderer_handed_over`, which is split out of the callback so it can be tested
2620 without a live `VkRenderer`). Images uploaded outside that callback are not
2621 replayed, so a cached id names nothing and its draws are skipped in SILENCE:
2622 the image just goes from the scene. The id is dropped and `page_dirty` asks the next
2623 tick to recompose and re-upload — the raster is cheap to rebuild from the node
2624 graph, and no id can be carried across renderers. Found by cce-1f's audit of
2625 clients caching vk image ids.
2626 
2627 **To test it**, put `CCE_UI_FAULT_RECONNECT=<seconds>` on the binary's
2628 environment: the runner drops the session that many seconds in, exactly as a
2629 transport error would, and the app reconnects with a new renderer. Run the OLD
2630 binary through the same fault first — a fix that passes a test which never
2631 reproduced the bug is worth nothing. Judge by the picture: the designer inits no
2632 logger, so the runner's WARN never appears even when it fired. Verified this way
2633 on 2026-09-19 — with the fix disabled the sheet vanishes at the fault, with it
2634 the sheet survives.
2635 
2636 `gem_graph`, the source family's
2637 everything-at-once node, is deliberately not ported: it is these nodes chained,
2638 and that collapse is the whole premise of "fifty operators, ten nodes".
2639 
2640 ### The pane plates: one material, one relief block
2641 
2642 Every plate the designer draws — the network panel, params, spreadsheet,
2643 playbar, and every collapsed stub — goes through
2644 `append_widget_plate_radii` in `render.rs`, and every one of those widgets
2645 answers `color()` with `cce_ui::colors::param_plate_fill`, which is
2646 `Material::pane()`: the toolkit's PANE rung. So there is ONE material for
2647 the designer's plates, configured in the `style.surface` block of
2648 `~/.config/cce/config.kdl` (a `~/.config/cce/cce-designer/config.kdl`
2649 merges over it key by key, when one exists), and the params plate and the
2650 spreadsheet's cannot be styled apart short of binding a named material
2651 (`plate material="…"`). The viewport has no plate of its own: its lip is the
2652 window's root-plate edge. What the block looks like after the 2026-09-28
2653 consolidation, and what each key is:
2654 
2655 ```kdl
2656 style {
2657     surface {
2658         plate {
2659             pane color=(rgba)"#6c6c7bf2"         // the tint, alpha = strength (legacy: param.color × plate_opacity)
2660             frost radius=(f64)5.5 compression=(f64)0.0 refraction=(f64)0.0   // the one frost spelling
2661             border_color (rgba)"#9595a9ff"        // the flat border, relief OFF only
2662             border_thickness (f64)1.0
2663             root { corner_radius (i64)24 }        // the pane corner radius falls back to this
2664         }
2665         relief light=(f64)0.15 width=(f64)9.3 shader=(bool)true {  // light: strength, NOT a length; width: the one roll/wall run;
2666             // shader: false = the legacy banded edge lighting, an A/B switch (was window_manager.bevel_shader)
2667             wall height=(mm)0.3 profile="smooth;…"   // a carve's wall: buttons, wells, param rows
2668             edge height=4.0    profile="smooth;…"   // a plate's perimeter roll: these panes
2669         }
2670         menu color=(rgba)"#101018ff"             // the dialog and every context menu (below)
2671     }
2672 }
2673 ```
2674 
2675 The rules that took a day to settle, each with the wrong version it replaced:
2676 
2677 - **One roll width.** `relief.width` is the run of every roll and wall: the
2678   root plate, a `PlateSpec` pane plate, these bordered widget plates (through
2679   `colors::plate_bevel_width`), every control wall, and the length
2680   `edge.height` is a rise against. Until 2026-09-28 the widget-plate path
2681   had `style.surface.plate.bevel_width` of its own (default 6 against the
2682   relief's 9.3), so the panes here and the window lip rolled over different
2683   widths and no single key made them match. The old key was an override
2684   for the rest of that day and is retired: reported by path at load with
2685   the other retired surface keys, not read, removed by cce-relief's Save.
2686 - **Wall and edge are two shapes, and the config names them.** A wall is
2687   shaded as a translucent overlay on what is under it; an edge multiplies
2688   the plate's own fill and adds a specular crest. Each node carries
2689   `height` (a length — the wall's drop, the roll's rise; unset = follow the
2690   width) and `profile` (a ramp spec; absent = the analytic curve). The flat
2691   spellings (`height` / `profile` for the wall, `edge_height` /
2692   `edge_profile`) and `depth`, the strength's former name (`light` now),
2693   are retired: reported at load, not read, rewritten by cce-relief's Save
2694   from a one-time seed. So is the whole `window_manager.bevel_*` block the
2695   relief was born in — `bevel_depth`, `bevel_width`, and `bevel_shader`,
2696   which is `relief.shader` now and takes a `(bool)`.
2697 - **cce-relief's knobs are not a style key.** The Shoulder / Base / Bias
2698   triples behind each profile (`wall.knobs` / `edge.knobs`, before that
2699   `profile_knobs` / `edge_knobs`) were editor state beside the values that
2700   draw; they live in `~/.config/cce/cce-relief/state.kdl`, one node per
2701   Save target. A config still carrying one seeds the editor once, and its
2702   next Save takes the key off.
2703 - **Frost is one block, and the flat keys are retired.** `plate.blur`,
2704   `.radius`, `.backdrop_compression` and `.refraction` are reported by path
2705   at load and not read — `blur=true` alone is a SHARP plate, and the warning
2706   is what says why. A named material's `frost` child spells its knob
2707   `compression` too.
2708 - **Focus is the bevel's tint.** With relief on (`window_manager.control_relief`,
2709   default on) AND the shader plates (`relief shader`, default on) —
2710   `render::focus_by_tint` — a plated pane marks focus by tinting its roll
2711   with `highlight_primary_color` (`plate_focus_tint`): the lit side in the
2712   accent, the shadow as dark as an unfocused one in the accent's hue
2713   (cce-ui's shader2d, `FOCUS_*`; until 2026-10-05 the shadow came out
2714   BRIGHTER than the lit side for a bright accent on a dark plate, and the
2715   ring read lit from the bottom-right). Otherwise
2716   `append_context_border` draws the flat ring: the banded A/B path draws a
2717   tinted bevel untinted, so with `shader=false` focus showed nowhere. The
2718   roll itself takes a `border_color` (the plates reach the relief through
2719   `solid_border`): without one there is no roll and no tint.
2720 - **The grid cursor is in the accent only while the network has focus**,
2721   in the plates' neutral `border_color` otherwise (since 2026-10-05; it
2722   was always the accent). Outside the circular pane it is the network's
2723   ONLY focus cue — the network has no plate to tint and no edge to ring,
2724   spanning the window — and with the shader off focus adds a flat accent
2725   ring around it.
2726 
2727 **An open dropdown in the params pane is painted into the frame**
2728 (`append_popovers`, since 2026-10-01): `render_popover` gets the frame's
2729 `PaintCtx`, as cce-files' does, so the menu is the trigger's plate grown,
2730 relief and corners included. It went through a `PopoverCollector` until
2731 then, which keeps fills as square rects, and the expanded plate came out
2732 flat and square-cornered.
2733 
2734 Two keys look like they apply and do not: `style.surface.plate.color`
2735 feeds `plate_color`, whose one consumer is the info box, and the finish's
2736 spec / shininess / curvature live as `relief.spec` / `.shininess` /
2737 `.curvature`, not under `plate`. The network pane is two layers: the
2738 `PassivePlate` above (no longer drawn: the network has no plate) and the
2739 Graph on top, which has NO fill of its own (since 2026-09-29): its cells
2740 are whatever it is painted on — the scene. `style.surface.graph` set only
2741 its lines (`grid_color`, `line_width`), their `opacity`, and `blur` — and
2742 the lines are not drawn since 2026-10-07 (below), so in this app the first
2743 two do nothing.
2744 Until then the graph filled itself with `cell_color` (chosen by a
2745 `uniform_background` flag this app hard-coded true) and drew its lines in
2746 `gap_color`; all three keys are retired, reported by path at load and not
2747 read.
2748 The network has no plate at all now: "The network has no plate" below.
2749 
2750 **Floors: a faint plate, compressed content** (since 2026-10-06). The way
2751 to make the plates recede and keep what is on them readable is to compress
2752 the scene under the CONTENT rather than under the whole plate:
2753 `style.surface.param.backdrop_compression` puts a floor of the pane
2754 material under each params row (cce-ui's `paint_row_floors`), and
2755 `style.surface.graph.node_compression` sets the node bodies' compression
2756 and, set at all, puts the same floor behind each node's NAME
2757 (`State::node_label_floors`, from the graph's own laid-out text, drawn in
2758 the bodies' run so they share its blur snapshot). Compression pulls the
2759 backdrop's luminance toward the material's tint RGB and ignores its alpha,
2760 so a DARK pane colour at a low alpha (`#2020280d`) is a plate that barely
2761 shows and a dark key under light ink: the mid-grey `#6c6c7b` it replaced
2762 capped full compression at about 3.3:1 against `#ccccd4`. 0.85 was where
2763 labels read over a bright model in a shadow session; 0.6 left them grey
2764 on grey.
2765 **The other way round is a light node with dark names** (since
2766 2026-10-06): `style.surface.graph.node_tint` (rgba, absent = the pane
2767 colour) tints the node bodies and the name floors alone, so a light
2768 tint under compression is a light node on a faint plate.
2769 `State::node_ink` writes the names dark (`NODE_DARK_INK`) when there
2770 are floors and the tint's linear luminance is past 0.179, where black
2771 and white ink contrast it equally; without floors a name stands on the
2772 bare scene and keeps the widget's light grey. Dark ink reads LIGHTER than
2773 its colour at label sizes: the glyph pass blends coverage in linear
2774 space, which thins dark-on-light strokes (a 14 px stem bottoms out near
2775 `#5c` on `#e6e8f0`). `a_node_name_on_a_light_floor_is_written_dark`.
2776 The params widget alone also reads `style.surface.param.backdrop_compression`.
2777 The rules live in cce-ui's CLAUDE.md ("There is one roll width", "The
2778 relief is two shapes", "Frost is one block"); this is the designer's view of
2779 them, written because the question "what are the style parameters of the
2780 plates" took a session to answer from the code.
2781 
2782 ### There is one network editor (since 2026-10-07)
2783 
2784 A second network editor — its own plate, graph and breadcrumb
2785 (`NETWORK_PANEL2_IDX` / `CONTENT2_IDX` / `BREADCRUMB2_IDX`), its own path
2786 (`current_path2`), placed through the plate menus' tab rows and closed by
2787 Close Tab — was removed, with what existed only to choose between two:
2788 `param_editor` (the editor that took the last click, which the params pane,
2789 the spreadsheet and the viewport followed) and the three PINS
2790 (`viewport_pin`, `params_pin`, `spreadsheet_pin`, the "Follow Active Editor"
2791 / "Pin: Network" radio rows of the viewport menu and the plate menus).
2792 `param_editor_selected`, `param_editor_dir` and `viewport_editor_dir` stay
2793 as names for what they read — the one editor's selection and level.
2794 
2795 An older save's `"network2"` dock entry, its `current_path2` and pins are
2796 ignored (`an_older_saves_second_network_editor_is_dropped`).
2797 
2798 ### There are no docks (since 2026-10-07)
2799 
2800 The floating layout had three DOCKS — a left column, a right column and a
2801 bottom strip — which owned the plate dimensions (`floating_network_layout`,
2802 `floating_param_width`, the spreadsheet's height and its side TUCKS under a
2803 side dock's plate), with panes assigned to them (`Dock`, `dock_panes`),
2804 held several to a dock as TABS (`dock_tabs`, Add Tab, Move To Own Plate),
2805 and swapped by the plate menus' **Move To** rows. The network, the params
2806 HUD and the second network editor left them one after another, which left
2807 the spreadsheet the one pane a dock could hold, and the model went whole:
2808 tabs first, then the docks.
2809 
2810 What is left is two plate edges. The **spreadsheet** is the strip along
2811 the bottom, a gap in from the window's left and a gap above the playbar,
2812 as tall as `floating_spreadsheet_height` asks (`floating_spreadsheet_rect`,
2813 the one derivation) and **as wide as its table** (since 2026-10-07,
2814 cce-ui's `Spreadsheet::content_width`: its columns, each as wide as its
2815 content), never narrower than `SPREADSHEET_MIN_W` (an empty table) and no
2816 wider than the window less a gap each side, where the table scrolls. It
2817 spanned the window, and a narrow table left most of the plate empty over
2818 the scene; now the params HUD runs down past a plate that stops short of
2819 it. A refill that changes the table's width lays out positions again at
2820 the end of `sync_selection_readouts` — positions only, since
2821 `sync_layout` also moves the grid cursor and with it the selection
2822 (`the_spreadsheet_plate_is_as_wide_as_its_table`). Its TOP edge resizes it (`on_spreadsheet_resize_edge`
2823 — its sides tucked it under a side dock's plate). The **params HUD**'s left
2824 edge sets `params_hud_width`. The plate menus hold the window actions alone
2825 — Collapse / Expand, Detach / Reattach. `PlateGeometry` saves those two
2826 (`hud_width`, `spreadsheet_height`); an older save's dock widths, tucks and
2827 `dock_tabs` are ignored, its `params_width` read as the HUD's when it has
2828 no `hud_width`, and a spreadsheet it had moved into a side dock opens along
2829 the bottom. `the_plates_have_no_docks` is the test.
2830 
2831 ### The network has no plate (since 2026-10-06)
2832 
2833 The network's nodes and wires stand directly on the 3D scene. The viewport is
2834 full-bleed (`CANVAS_IDX` covers the window and the other panes float over it),
2835 so with no plate under the graph what is behind it is the scene. Node bodies
2836 keep their blur-behind fill, which is what keeps them legible: they frost the
2837 scene behind each node while the gaps stay clear.
2838 
2839 It was a switch for a while — `toggle_network_plate` (Shift+P), the
2840 `network_plate` field on `State` and in the viewport settings, a row of the
2841 network menu and the menubar — with the plate on until the morning of
2842 2026-10-06, off by default that afternoon, and gone that evening, with every
2843 branch it gated reduced to the plate-off one. A state.kdl or a project's
2844 display block that still says `network_plate` is read as if it did not (the
2845 settings structs ignore keys they do not know), and the next save drops it.
2846 What only the plate reached went with it: the network's flat focus ring
2847 (the grid cursor carries its focus), the network panels as plates over the
2848 params HUD (`plates_over_params`) and over a point number (`under_a_plate`).
2849 
2850 The pane keeps its focus domain, its menus, its clip and its keyboard
2851 navigation.
2852 
2853 **The network is in no dock** (it left the left dock on 2026-10-07, and
2854 the docks went the same day — see "There are no docks"): the spreadsheet
2855 runs flush to the window's left, where it stopped short of the network's
2856 invisible dock until then. The network's plate menu is Detach alone, and
2857 in no menu — the network menu's Plate page went too, Detach being the
2858 palette's `detach_circular_window` — and `set_pane_collapsed` refuses it.
2859 `plate_at` never answers the network. It had a resize edge of its own
2860 (`network_resize_edge_at`, `NetworkResize`), gone with the dock. And a
2861 press on the network's breadcrumb is asked where the breadcrumb is drawn:
2862 it was asked in the old dock's top strip, so a press in that band of the
2863 scene focused the network and went nowhere. `the_network_is_in_no_dock`.
2864 
2865 **The pane spans the whole window** (`network_overlay`, which is true while
2866 the network is shown, not circular and not a detached window), laid out in
2867 `rebuild_positions` as one `let (px, py, pw, ph)` — the body, or nothing
2868 while hidden — so content, panel and breadcrumb all follow: there is no
2869 surface to bound the graph, and one confined to a rectangle you cannot see
2870 is worse than one that spans what it is drawn over.
2871 
2872 That makes the pane's RECT useless as a hit test, and these route off it:
2873 
2874 - **Clicks** ask `in_network_pane`, which in overlay mode narrows to "a node is
2875   under the cursor, and no floating pane covers it" (`overlay_claims`). The
2876   same refinement goes into the press cascade's `hits_widget` closure, where
2877   the circular pane already refines its own hit test. Without it the graph
2878   claims every press in the window, including ones landing on a node drawn
2879   UNDER the params pane.
2880 - **Pan gestures** ask `in_network_area` instead — the plain rect. Middle-drag
2881   and space+left mean nothing to the scene, so the network keeps them across
2882   its whole span; a graph you could not pan by dragging because its own surface
2883   stopped being drawn would be a strange thing to ship.
2884 - **The wheel** is decided per GESTURE (since 2026-10-06,
2885   `State::overlay_wheel_to_graph`): one begun on a node pans the graph, one
2886   begun anywhere else orbits the camera, as a click would. Until then the
2887   graph took every scroll by its window-wide rect, so without the plate a
2888   trackpad could not orbit at all. The target is held (`State::overlay_wheel`)
2889   to the finger's lift, a pause of `OVERLAY_WHEEL_GAP` or the pointer
2890   moving: a pan slides the node out from under a pointer that does not
2891   move, and the rest of the swipe would otherwise turn the camera. The
2892   ctrl zoom follows the same target.
2893   `a_scroll_over_the_plateless_network_orbits_unless_it_begins_on_a_node`.
2894 - **Box selection is ctrl+drag**: a ctrl+left press on empty space is the
2895   network grid's empty-grid press — the cursor to the cell, an expansion drag
2896   armed from it, settled on the release (see "The cursor is a region") —
2897   where a plain press there orbits. The network takes focus with it.
2898 - **`cursor_in_viewport`** becomes the complement: the viewport's rect, minus
2899   what the network holds, minus the floating panes, minus the second
2900   editor's rect. Until 2026-10-06 it was bounded by the old column split
2901   (`splitter2_x`) instead, so the scene under the params HUD's rows, right of
2902   it, could be neither orbited nor right-clicked.
2903 - **A hidden network has no area** (`in_network_area`), so the middle
2904   button over the scene pans the camera then; shown, it is the graph's
2905   everywhere.
2906 
2907 What changes for the user: a plain click on empty space is not the network's
2908 — it ORBITS THE CAMERA instead (see below), which is what makes the overlay
2909 feel like a scene with a graph on it rather than a graph with a picture
2910 behind it. Deselecting is on Escape.
2911 
2912 ### The params plate fits its rows, and is optional (since 2026-10-06)
2913 
2914 The params HUD's plate is FITTED to its rows: from the HUD's top to as far
2915 under the last row as the first row stands under the top, so it is padded
2916 alike above and below, and it grows and shrinks with the node shown — not
2917 the HUD's rect, which runs the viewport's height. **With no rows to show
2918 (nothing selected, a node without parameters) it collapses into a small
2919 circle** (`PARAMS_DOT_D`, 36 px) in the HUD's top right corner, which the
2920 HUD claims, and grows back out of it when rows return. `params_plate_target`
2921 is where it is heading, `[x, y, w, h, round]` (round 1 the circle);
2922 `State::params_plate_shown` eases toward it each tick
2923 (`animate_params_plate`, exponential like the drop glow);
2924 `params_plate_drawn` is the frame's, its corners rounding toward half the
2925 side. Settled on the rows it is the bevelled plate every pane wears; the
2926 circle and the way between are `PaintCtx::plate_shaped` with the corner
2927 exponent eased toward 2 — circular arcs, since the DE's squircle at full
2928 radius is a rounded square and not a circle — and the rolled edge toward a
2929 dot's, cce-browser's bar-from-its-corner-control morph. The rows are
2930 clipped to the plate as drawn, so they are revealed as it grows. A circle
2931 has no edge to resize. `the_params_plate_collapses_to_a_circle_with_no_rows`. It can go, as the network's can, leaving the controls
2932 directly on the scene: `params_plate` on `State` and `ViewportSettings`
2933 (state.kdl and the project's display block; ON by default and when absent
2934 — it was off for an afternoon, so a state.kdl from then says `false`), the
2935 `toggle_params_plate` command (**Parameters Plate**, a switch in the
2936 palette, unbound) and `Action::ToggleParamsPlate`. The render arm draws it
2937 with `render::append_plate_at` — `append_widget_plate_radii` at a given
2938 rect, since that one draws at the widget's own.
2939 The PARAM_IDX render arm skips the plate and the scrollbar's idle copy —
2940 which only ever showed faintly through the frost; the bar is seen when a
2941 scroll raises it — and `append_context_border` draws no flat ring around a
2942 pane with no edge. The wells and labels are ParametersBg's own and are
2943 unchanged; with no enclosing plate the wells shade as overlays, which was
2944 checked in a shadow session and reads cleanly over the grid.
2945 
2946 **The HUD is its ROWS, plate or not.** `State::params_claim` is the
2947 band from the HUD's top to just under its last row — the fitted plate
2948 with the plate on, `PARAMS_CLAIM_PAD` under the last row without it, the
2949 whole rect when the rows fill it — and
2950 `params_claims` adds an open dropdown, which grows past the rows. It is
2951 what `over_floating_pane_at` reads for the pane, so `cursor_in_viewport`,
2952 `under_a_plate` (a point number under the empty part draws) and the
2953 network overlay's claim follow it; the press cascade's `hits_widget` and
2954 the wheel loop ask it too, so a press or a scroll under the rows orbits
2955 and zooms the scene, and `on_param_resize_edge` runs only as far as the
2956 rows. `the_params_plate_fits_its_rows` and
2957 `a_point_number_under_a_plate_is_not_drawn` are the tests.
2958 
2959 ### The params pane is a HUD on the scene (since 2026-10-06)
2960 
2961 The params pane is a HUD on the scene viewer, drawn right after it and
2962 under every plate, and its size has no relation to any plate. Until this
2963 it was the right dock's pane (the docks went the next day) — as wide as that dock, its
2964 bottom raised by a spreadsheet tucked under it, tabbable and movable.
2965 
2966 - **Laid out from the viewport** (`State::params_hud_rect`): the
2967   viewport's top-right corner a gap in, `params_hud_width` wide (its own
2968   field; saved as `PlateGeometry::hud_width`, and an older save's
2969   `params_width` is read as it), as tall as the viewport — but **it stops a gap above the
2970   spreadsheet or the playbar when one lies below it** (since later the
2971   same day; for an afternoon they covered its bottom, and rows under them
2972   could be neither seen nor reached). What does not fit then scrolls, the
2973   pane's own scrolling, and the fitted plate fills the HUD. Laid out again
2974   after the collapse and detach post-passes in `rebuild_positions`, so a
2975   stubbed spreadsheet is what it stops above. Its left edge drags its width
2976   (`AppDrag::HudResize`, `on_param_resize_edge`), as far down as it claims.
2977 - **Out of the docks.** Its plate menu has no Collapse row (Detach stays)
2978   and `set_pane_collapsed` refuses it. The legacy column branches (circular network, detached circular
2979   window) still place it in their right column.
2980 - **Under every plate.** Draw order: viewport (-7), the HUD (-6), the
2981   network panels (-5, drawn with no plate), then the rest.
2982   `plates_over_params` is the plates over it (the spreadsheet, the
2983   playbar, stubs included; the network editors have none); `params_claims` takes them
2984   out, so a press, the wheel, a row's right-click (`param_row_at`) and
2985   `plate_at` there are the plate's. Text is the hard part: the engine lays
2986   ALL text out after all geometry, so a HUD label under a plate would be
2987   drawn over it. The render arm paints the HUD into what the plates leave
2988   of it, a rect at a time (`render::uncovered`, one rect most of the
2989   time), so its labels and controls stop at a plate's edge.
2990 
2991 `the_params_hud_is_under_the_plates_and_stops_above_the_bottom_ones` and
2992 `uncovered_takes_the_covers_out_of_a_rect` are the tests. Checked in a
2993 shadow session: the spreadsheet and playbar over the HUD's lower rows, no
2994 label through them, the HUD's size unmoved.
2995 
2996 ### Deselecting has to stick
2997 
2998 The selection IS whatever sits in the grid cursor's cell — that is what
2999 `sync_cursor_and_selection` means — and that sync runs on nearly every frame
3000 where anything changed. So `set_selected_node(None)` alone does not deselect:
3001 it is put straight back on the next frame, and the pane never clears.
3002 
3003 `State::deselect_node` therefore remembers the CELL it happened in
3004 (`deselected_cell`), and the sync leaves that one cell alone. A cell rather
3005 than a flag, so the suppression is exactly as narrow as it should be: step the
3006 cursor anywhere else and selection resumes by itself, and stepping back onto
3007 the node selects it again. A selection arriving from anywhere else — a click, a
3008 load, the params pane — spends the memory at the top of the same sync, or
3009 clicking the very node you just deselected would clear itself again.
3010 
3011 Escape runs it LAST, after the context menus, the viewer state and
3012 connection-cancel: Escape is this app's one "get me out" key, and all of those
3013 are more immediate than a selection. There is also a `deselect` command, shipped
3014 UNBOUND so it is findable in the palette — deliberately not Ctrl+D, which the
3015 plugin uses for deselect-all but which this app already gives to Circular Pane.
3016 
3017 ### The network editor's right-click menu
3018 
3019 A right press on EMPTY graph space opens the network's own context menu; a press
3020 ON a node still opens that node's menu, which is the more specific thing under
3021 the pointer. Until 2026-09-22 the empty-space press opened the **add-node
3022 palette** outright, which left the network the one pane whose right-click was
3023 not a context menu, and left every other graph-wide command reachable only by
3024 chord or through the palette. **Add Node is the first row** instead, a PAGE
3025 row (see "Page rows" below): a press, or a side swipe forward over it, turns
3026 the menu into the same palette ON THE MENU'S CORNER (since 2026-10-01), and a
3027 swipe back turns the palette back into the menu. `Dialog::anchor` holds
3028 the corner and `dialog::layout_at` places the plate there, giving up height
3029 (down to `ANCHORED_MIN_H`) before it moves up and pulling in from the right
3030 edge; every other opening clears the anchor and centres, Tab's Add Node
3031 included.
3032 
3033 ### Page rows: a menu turns into what a row names (since 2026-10-02)
3034 
3035 `src/menu_page.rs`. A row of a context menu that leads to another plate is a
3036 PAGE row (cce-ui's `context_menu::set_row_page`; see its CLAUDE.md, "A row
3037 can lead to a page"): it wears `›`, and a press on it, or a two-finger swipe
3038 to the side with the pointer on it, TURNS the menu into what it names with
3039 the new plate's top-left where the menu's was. A swipe the other way, from
3040 anywhere on the new plate, turns back; a page of rows also has a back band
3041 (`‹ Viewport`) for a press. Under natural scrolling forward is the fingers
3042 going LEFT, as the content goes (cce-ui's `side_swipe`). Until this the
3043 viewport menu's Style and Markers flew a second menu out on hover while the
3044 other rows below swapped the plate on a click — some with a Back row, most
3045 with no way back — two gestures for one idea.
3046 
3047 The page rows: the viewport menu's **Style** and **Markers** (pages of rows;
3048 and **Network**, the network menu, while the network overlays the scene
3049 (not circular);
3050 its **Attribute Visualizers** row was one too, into the dialog, until
3051 2026-10-06 — it is a plain row now, opening them in the params HUD); the
3052 network menu's **Add Node**
3053 (the dialog); the playbar menu's **Plate** (its plate rows); the node
3054 menu's **Rename** (the dialog). Inside the dialog the rows
3055 that turn it into another list are marked `›` in the chord column and take
3056 the forward swipe too (`dialog::dialog_row_leads`): the palette's Group
3057 Markers. The mark rides the row's chord TEXT (cce-ui's `PAGE_MARK`), and a
3058 label that begins with `BACK_MARK` wears the other; the dialog's row painter draws them as the `chevron-right` /
3059 `chevron-left` glyphs, as the toolkit menu does, never as the characters
3060 (since 2026-10-05, the cce-icons rule: every symbol the app draws is a
3061 glyph). The menus' `● ` / `○ ` switch marks are cce-ui's `MARK_ON` /
3062 `MARK_OFF`, which the toolkit draws as `circle` / `circle-outline`.
3063 
3064 - **`State::run_menu_turn` is the one dispatch**, reached by a left press
3065   (`press_menu_turn`, ahead of every menu's own click handler) and by a swipe
3066   (`take_menu_turn`, from the wheel arm, which now routes the wheel to ANY
3067   open menu, not only the slider menus). `MenuOrigin` names the menu a turn
3068   came from; `reopen_menu` shows it again at a corner (each menu's opener
3069   takes an `at`, through `put_up_menu`). The wheel arm first asks cce-ui's
3070   `side_swipe::swallow`: what is left of a swipe that turned is dropped,
3071   so the end of a swipe back from the wide Add Node list does not orbit
3072   the scene the narrower menu uncovers.
3073 - **A page of the viewport menu stays up while its rows run**: a switch
3074   flips and is re-marked in place (`refill_viewport_menu`, cce-ui's
3075   `refill`), a slider is worked; a row of the menu itself runs and closes
3076   it, as before.
3077 - **The dialog remembers where it was turned from**: `State::dialog_from`,
3078   the menu (shown again at the dialog's corner by a swipe back), and
3079   `State::dialog_trail`, the modes it turned through while up — a mode
3080   opened while the dialog is up keeps the plate where it stands and puts
3081   the mode it leaves on the trail, and turning to the trail's last (by a
3082   swipe back) takes it off. The palette's Group Markers row runs with the
3083   palette still up, so it is on the trail. Opened afresh, the dialog
3084   has neither. A swipe back with neither does nothing.
3085 
3086 - **Every turn is animated** (cce-ui's `TURN_MS`, 180 ms): a page of rows
3087   by cce-ui itself; the dialog by `dialog::DialogTurn` the same way — the
3088   plate grows from the menu just put down (`open_dialog_from` reads its
3089   size off the hidden context menu), and a mode turned to while it is up
3090   (Group Markers and back) slides its rows in from the side
3091   it came from at the plate's own size. The render arm paints a turning
3092   dialog's content aside and replays it moved, clipped and faded
3093   (cce-ui's `Prim::faded`, geometry and text alike), its text bounded by
3094   the plate as drawn, which is also the occluder the dialog
3095   claims meanwhile (`Dialog::drawn_rect` in `popover`), so the clamp still
3096   lets the labels through. A swipe back from the dialog shrinks the menu
3097   out of the dialog's size (`context_menu::turn_from_size`). Checked in a
3098   shadow session under `CCE_UI_TURN_MS=2000`.
3099 
3100 `the_viewport_menu_turns_into_its_pages_and_back` drives the viewport
3101 menu's pages, the back band and both swipes, into the dialog and back.
3102 
3103 Rows are `NETWORK_MENU_COMMANDS` — a list of COMMAND IDS, `None` for a
3104 separator — resolved through `command::by_id`, so a label is the registry's
3105 label and `NetworkMenuAction::Command(id)` dispatches through `run_command`.
3106 The menu therefore cannot name work the palette spells differently, and a row is
3107 exactly as scriptable as the command behind it.
3108 `network_menu_rows_name_commands_that_exist` is the backstop, since a row whose
3109 id no longer resolves is simply skipped. A toggle command carries the viewport
3110 menu's `●`/`○` mark, read through `command_toggle_state` — the one table the
3111 dialog's switches read too.
3112 
3113 `add_node` is a registry row of its own now (`Run::Menu("Add Node")`), where the
3114 palette used to be reachable only from Tab's inline handler. It ships UNBOUND,
3115 like `deselect`: Tab already opens it from the event loop, and a default chord
3116 here would duplicate a key the loop claims.
3117 
3118 Outside the circular pane the press never gets here — `in_network_pane` narrows
3119 to the nodes in overlay mode, so empty space is the scene's and opens the VIEWPORT
3120 menu. That is the overlay's whole rule, and it predates this menu. So in
3121 overlay mode (always, outside the circular pane, since 2026-10-06) **this menu is a page of the
3122 viewport's**: a **Network** row heads the viewport menu
3123 (`ViewportMenuAction::NetworkPage`) and turns it into this one under a
3124 `‹ Viewport` band (`State::network_menu_from`). The viewport menu's press
3125 remembers the cell under it when it lands in the network's area
3126 (`State::network_menu_cell`), and the turn (`open_network_menu_from_viewport`)
3127 puts the grid cursor there and focuses the network — so Add Node places
3128 where the menu was opened, and Frame Cursor, gated on the network's focus,
3129 acts. Merely opening the viewport menu moves no cursor: the cursor is the
3130 selection, and a right-click to flip a display switch must not deselect.
3131 `the_network_menu_is_a_page_of_the_viewport_menu_without_the_plate` is the
3132 test.
3133 
3134 The press moves the grid cursor to the clicked cell BEFORE the menu goes up,
3135 because that cell is where Add Node will place what it adds — the cursor is the
3136 only thing carrying the pointed-at cell across to the palette.
3137 
3138 ### Keyboard graph navigation
3139 
3140 The network pane's keyboard scheme is the plugin's, ported: **hjkl rather than
3141 arrows** — the arrows are the playbar transport in every pane and context — bare
3142 to move the grid cursor, `shift` to extend it into a region, `alt` to move the
3143 selected nodes, `ctrl` to pan the view, plus `f` to frame the cursor and
3144 `shift+f` to frame everything. All eighteen are registry commands in
3145 `Context::Network`, so they are rebindable through `input.kdl` and listed in
3146 the palette.
3147 
3148 **The grid cursor IS the selection.** `sync_cursor_and_selection` selects
3149 whatever node sits in the cursor's cell, so navigating selects, and stepping off
3150 a node deselects. Every family is gated on the network pane having focus — one
3151 gate, in the four `network_*` methods. The bare family used to be the one that
3152 was NOT gated: plain h/j/k/l moved the cursor from any pane, so it drifted
3153 invisibly while you were looking at the viewport (the selection did not follow,
3154 because `sync_cursor_and_selection` has its own pane check) and was somewhere
3155 unexpected when you came back.
3156 
3157 `alt` moves the SELECTION and the cursor, so a run of `alt+h` drags what is
3158 selected across the sheet rather than leaving it behind on the first press. `ctrl` pans by one CELL
3159 rather than a fixed pixel count, so a pan step means the same thing at every
3160 zoom. Frame Cursor CENTRES the cursor cell; its first version called
3161 `keep_cursor_in_view`, which pans only when the cursor has gone off an edge, so
3162 the command did nothing at all in the common case of a cursor that is visible
3163 but off in a corner — which is exactly when it gets pressed.
3164 
3165 `shift+hjkl` — the plugin's extend-the-selection family — grows the cursor's
3166 region (below) by one cell. It was absent while the graph's single
3167 `selected_node` was the whole selection, when four rows would have done what
3168 bare hjkl already does; there is a real multi-selection to extend now.
3169 
3170 **The anchor never moves.** `network_extend` walks the region's FAR corner and
3171 leaves the anchor where it is, exactly as a drag does, so `shift+l` then
3172 `shift+h` returns to where it started rather than walking the whole region
3173 right and back. A far corner that meets the anchor again drops the expanse
3174 outright, so a region shrunk to nothing is the plain one-cell cursor and not a
3175 1×1 region that merely behaves like one — and carrying on past the anchor grows
3176 it the other way. Extending from a cursor that sits ON a node keeps that node
3177 selected, the anchor's cell being part of its own region, which is what makes
3178 the family an extend rather than a second way to start a selection.
3179 
3180 It scrolls the FAR cell into view (`keep_cell_in_view`, which
3181 `keep_cursor_in_view` is now a one-line wrapper of): the anchor is the end that
3182 is not moving, and following it would scroll the wrong end of the selection
3183 into view.
3184 
3185 **Frame All fits the name labels, not just the bodies.** A label hangs off
3186 its node's right edge (`Graph::node_labels`: an 8 px gap and a 14 px font,
3187 both scaled with the body against its 80 px baseline, the font clamped to
3188 6..48), so framing the bodies alone cut the right-hand column's names off.
3189 `State::node_extent` repeats that rule — the widget offers no query for it —
3190 using the widget's own `TextLabel::estimate_width`, the number it culls the
3191 label against, so the two cannot disagree. The fit is iterated rather than
3192 solved once, because the extent is not linear in the zoom: the font floor and
3193 the width's rounding mean a fit computed at 100% overstates what a small zoom
3194 saves. `frame_all_keeps_the_node_labels_inside_the_pane` is the check, and it
3195 fails on the body-only fit.
3196 
3197 Two chords moved to make room, both caught by `command::conflicts` rather than
3198 by hand: `edit_handles` from `Ctrl+H` to `Ctrl+Shift+H` (the ctrl+hjkl family
3199 owns those now), and `f` now frames the CURSOR where it used to frame
3200 everything, with framing everything on `shift+f` — the plugin's split.
3201 
3202 ### The network grid is a lattice, and a node sits on a crossing
3203 
3204 The network grid has ONE size per axis: `style.surface.graph.spacing_x` /
3205 `spacing_y` in config.kdl, the pitch — the distance from the centre of one
3206 grid line to the centre of the next. A node's `position` (col, row) names a
3207 lattice intersection, and the node body is CENTRED on it. The body has a
3208 size of its own, `style.surface.graph.node.width` / `height`, which the
3209 pitch does not touch: a denser grid moves nodes closer, it does not shrink
3210 them (a first cut derived the body from the pitch; it was disconnected the
3211 same day). Until 2026-09-22 the grid was rounded CELLS with grout between
3212 them, configured as a cell size (also the node size) plus a gap, and a node
3213 filled its cell.
3214 
3215 **Which file sets the pitch is easy to get wrong.** cce-ui merges the
3216 per-app override `~/.config/cce/cce-designer/config.kdl` OVER the main
3217 `~/.config/cce/config.kdl`, key by key, so a `spacing_x` in the per-app
3218 file wins over any edit to the main one — a whole afternoon of "the grid
3219 size is not changing" (2026-09-22) was a stale `spacing_x=71` in the
3220 override, left from the cell model. `get_state` over MCP reports `grid`
3221 (the live pitch and node size, the zoom percent, and the CONFIGURED pitch
3222 and node size), which is the one way to check from outside that a config
3223 edit reached the lattice.
3224 
3225 `State::grid_pitch_x` / `grid_pitch_y` and `node_w` / `node_h` are the
3226 zoomed geometry — `configured_grid_geometry` at 100%, scaled TOGETHER by
3227 `scale_grid_geometry`, which is the only relation between them. **A
3228 config.kdl edit to the spacing or node size shows at once** (since
3229 2026-10-06): `State::grid_base` is the configured geometry the live one is
3230 a zoom of, and the config reload (`update_graph_settings_from_config`)
3231 re-applies a changed one at the zoom in hand — until then the live
3232 geometry was read at startup and only zoomed after, so an edit waited for
3233 Reset Zoom or a restart (`a_grid_spacing_edit_applies_at_the_zoom_in_hand`). `cell_center`, `cell_rect` and `cell_at` are
3234 the three derivations every consumer goes through — the cursor outline, the
3235 click-to-cell of an empty-space press (`round`, not `floor`, because a cell
3236 is centred on its crossing and a click between two nodes belongs to the
3237 nearer), Frame Cursor, the zoom anchor. The configured geometry is the 100%
3238 baseline Reset Zoom returns to and Frame All scales down from (never past
3239 100%); `MIN_PITCH_*` / `MAX_PITCH_*` are the old node-body zoom limits
3240 expressed on the pitch.
3241 
3242 **The lattice is not drawn** (since 2026-10-07, at the user's request).
3243 cce-ui's `Graph::paint_grid` — lines in `grid_color` at the network
3244 opacity, `style.surface.graph.line_width` px, with the two through the
3245 (0, 0) crossing heavier as the origin axes — is not called, the widget is
3246 told `set_show_network_grid(false)`, and the always-true
3247 `network_grid_visible` flag and the `graph_grid_color` plumbing that fed it
3248 are gone. Everything the lattice MEANS stays: where a node stands, the
3249 snap, the pitch and zoom, the grid cursor and its region. Its
3250 cell-and-gap setters (`set_grid_sizes` / `set_skipped_sizes`) survive as a
3251 description of the same lattice for cce-files and cce-graph, which still
3252 speak it; this app sets the pitch.
3253 
3254 ### Node wires have a style
3255 
3256 The network's wires are drawn by cce-ui's `Graph::paint_wires` (see its
3257 CLAUDE.md, "A graph's wires are strokes in a style"), called in
3258 `render.rs` under the node bodies, and come in four styles: Orthogonal,
3259 Rounded, Bezier, Straight. **Node Wire Style** is a dialog Settings row
3260 (Alt+D, "wire"), a `Ctl::Choice` whose value lives on the Graph widget
3261 itself (`State::set_node_wire_style`); it persists as
3262 `ViewportSettings::node_wire_style`, so in state.kdl and with the project's
3263 display block. Empty — every file from before the row — hands the choice
3264 to config.kdl's `style.surface.graph.node.wire_style`, and the row then
3265 reads what the config says. Not to be confused with the wireframe's
3266 "Wire" rows beside it, which are the 3D edges.
3267 
3268 **A config.kdl edit repaints** (the same day): `tick_frame`'s config poll
3269 reloaded the style registry and returned nothing, so a changed key showed
3270 only when something else drew — a wire style set in the config appeared on
3271 the next hover. `the_node_wire_style_is_a_setting_the_project_keeps` is the
3272 test for the row.
3273 
3274 ### The cursor is a region, and dragging the grid grows it
3275 
3276 A left press on EMPTY grid — with ctrl held, outside the circular pane, where
3277 a plain press on empty space orbits the camera (see "The network has no
3278 plate") — puts the cursor on the pressed cell — on the press, not the release — and arms an expansion drag from it. Dragging grows the cursor
3279 from that anchor to the cell under the pointer. `State::grid_cursor_region` is the one derivation,
3280 `(col, row, cols, rows)`, never smaller than one cell; `grid_cursor_rect` is the
3281 window-space union the outline is painted on, which for the usual one-cell
3282 cursor is exactly `cell_rect` of it.
3283 
3284 **The release SETTLES the region** onto what it caught
3285 (`settle_cursor_expansion`): the bounding box of the selected nodes, or — with
3286 nothing caught — one cell at the MIDDLE of where the region stood, even spans
3287 rounding down toward its first cell. A region is a way of pointing at nodes,
3288 and once the pointing is done the empty margin the pointer swept through is
3289 noise: it hides nothing, it selects nothing, and it leaves the next alt+hjkl or
3290 Add Node reading off an anchor out in open grid. Settling also makes the region
3291 say what was selected — a box drawn loosely around two nodes comes back fitted
3292 to them. Nothing caught settles to the middle rather than back to the anchor,
3293 because the anchor is merely where the gesture began and a drag that selected
3294 nothing is aimed at the space it ended up circling.
3295 
3296 The SELECTION never changes in a settle — the bounding box of the selected
3297 nodes contains no cell the region did not — which is what lets it run at the
3298 end of every drag without a thought for what it might drop.
3299 
3300 **The region collapses by itself.** `grid_cursor_expanse` stores the anchor
3301 alongside the far cell, and `grid_cursor_region` hands it back only while that
3302 anchor is still `(grid_cursor_col, grid_cursor_row)`. So every OTHER way the
3303 cursor moves — a nav key, a click, a load, the selection following a node —
3304 leaves the anchor behind and drops the region with it, without a line in any of
3305 those places. Fifteen call sites write the cursor; a flag reset by hand at all
3306 of them is a flag that gets missed at one, and a cursor left stretched across
3307 the sheet is not a subtle wrong.
3308 
3309 **An expanded cursor selects every node standing inside it.**
3310 `State::selected_slots` is the selection, and it has two arms for a reason:
3311 one cell — the ordinary cursor — DEFERS to the graph's own `selected_node`,
3312 so nothing about a single selection changes (that one answer already carries
3313 the deselect memory, a click that arrived from another pane, and a selection
3314 made while the network was not focused); an expanded cursor names every node
3315 on a cell it covers instead. Its anchor is empty grid by construction — a
3316 press on a node drags the node — so there is no single selection to defer to.
3317 
3318 The network's operations act on that selection: **Delete**, the **`e`**
3319 geometry toggle, **Ctrl+C/X**, **alt+hjkl**, and the **mouse**. Two rules worth keeping:
3320 deletions run HIGHEST SLOT FIRST, or removing one shifts the slots above it
3321 and the second removal takes the wrong node; and the `e` toggle sets the whole
3322 selection to the opposite of the FIRST node's flag rather than flipping each,
3323 because a toggle over a mixed selection should settle it, not shuffle it.
3324 `network_move_node` moves the region along with the nodes — stepping the
3325 anchor alone is precisely what collapses a region, so the first alt+h would
3326 otherwise drop the selection it had just moved. The clipboard is a `Vec`, and
3327 a paste keeps the SHAPE it was copied in: the set's top-left lands on the
3328 cursor and each node keeps its offset, with a node whose cell is taken
3329 stepping aside to the nearest free one.
3330 
3331 **Dragging a selected node carries the whole selection** (`NodeDragGroup`,
3332 `drag_group_to`). The widget drags ONE node — it has one `dragging_idx` — so
3333 the companions are moved here, rigidly, by the offset the dragged node has
3334 travelled, measured from the cells they started on rather than stepped each
3335 frame (a drag is continuous but resolves to whole cells, so accumulating the
3336 steps would drift the group apart the first time two motions named one cell).
3337 They are NOT walked off occupied cells the way the widget walks the node it
3338 drags: a selection that rearranged itself around whatever it passed over would
3339 not be the selection you picked up — the same bargain alt+hjkl has always made.
3340 The preview follows `drop_target_cell_rect`, which runs `commit_drag`'s own
3341 resolution, and the release re-lays them from the cell that actually committed,
3342 since the widget can walk the dragged node a cell aside from the preview.
3343 
3344 Two things make that gesture work at all. **A press on a node inside the
3345 selection leaves the cursor alone**: the press path otherwise moves the anchor
3346 onto the pressed node, which is exactly what collapses a region, so the
3347 selection would be gone before the drag began. A press on a node OUTSIDE the
3348 selection does move it, and that collapse is the right one — clicking an
3349 unselected node selects that node. And `read_panel_offsets` returns early while
3350 a group drag is live, for the same reason: it yanks the cursor onto the
3351 selected node's cell, and the anchor is deliberately standing still. On release
3352 the region is shifted by the committed offset, as alt+hjkl shifts it.
3353 
3354 Escape collapses the region (`deselect_node`), because of the two selections
3355 this is the one that needs clearing: a single selection under a plain cursor
3356 comes back on the next sync anyway, while a region stands until the cursor is
3357 moved off its anchor.
3358 
3359 Everything that reads the cursor as ONE CELL still reads the anchor: Add Node
3360 places there, Frame Cursor centres it, `sync_cursor_and_selection` sets the
3361 graph's own selection from it. That single selection is deliberately NOT set
3362 from the region: `read_panel_offsets` yanks the cursor onto the selected
3363 node's cell, which would move the anchor off its own region and collapse it
3364 on the next layout sync. So with a region up the params pane shows nothing —
3365 it shows one node's parameters, and the selection is many.
3366 
3367 The paint reads the same `grid_cursor_covers`: a node body is recognised by
3368 the cell it is centred on and drawn with the highlight tint the widget gives
3369 its own single selection, rather than by a second rect test that could
3370 disagree with the selection itself.
3371 
3372 Arming is gated on the graph NOT having taken the press (`widget_took`). The
3373 case that bites is a press on a PORT: it starts a connection and consumes the
3374 press without selecting anything, so the empty-grid arm would read it as bare
3375 lattice and then swallow every motion event — leaving the rubber-band line
3376 frozen at the port it started from. The gesture is otherwise uncontested,
3377 because `Graph::draggable` is true only while it is moving a node.
3378 
3379 ### Auto-layout
3380 
3381 `src/layout.rs` arranges a level's nodes from their wiring. The network is
3382 already a GRID — positions are integer cells and the keyboard cursor steps cell
3383 by cell — so this is a layered assignment on cells, not a force-directed
3384 sprawl: a node's ROW is how far downstream it is, its COLUMN is chosen to sit
3385 under what it reads from.
3386 
3387 **Edges come from the same rule the wires do** — every wire the network draws
3388 (`app::node_wires`), which is the widget's `wire_pairs` derivation. Matching it
3389 is the point: a layout computed from relationships you cannot see would move
3390 nodes for reasons that are not on screen. Since second operands became wires
3391 (2026-09-30) they are edges too, but only for the ROW (`LayoutNode::reads`): a
3392 node sits below everything it reads, and under its `Input` alone, so a chain
3393 stays vertical and a Boolean's `With` does not drag it sideways.
3394 
3395 Flow is downward, matching every project in the repo (a Sphere at (4, 2)
3396 feeding an output at (4, 3)). Row is the LONGEST path from a root, not the
3397 shortest, so a node always sits below every one of its inputs rather than
3398 beside one of them. Depth is computed by iterating to a fixed point rather than
3399 by recursion, because a name-wired graph can be cyclic — A reads B reads A is
3400 something a user can type — and the loop stops improving instead of
3401 overflowing the stack.
3402 
3403 Utility trees are pinned: the settings node lives where the user put it, and an
3404 "arrange everything" that relocated it would be a surprise every time. Their
3405 cells count as occupied so nothing lands on top of them. Within a row, a node
3406 wants its parent's column (a root wants the column it already has, which
3407 preserves the left-to-right order among independent chains) and takes the
3408 nearest free column to that, searching outward — so a chain stays perfectly
3409 vertical and a collision nudges one node aside instead of shifting the whole
3410 row.
3411 
3412 `arrange` returns only the nodes that MOVED, so `layout_current_level` can say
3413 "moved 3 nodes" or "every node was already in place" — an arrange that did
3414 nothing because the layout was already right looks identical to a broken one,
3415 and the status line is the only thing that separates them.
3416 
3417 The command is `layout_nodes` on `Ctrl+Shift+L` rather than the bare `L`
3418 Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
3419 select family this app cannot implement until the Graph widget has
3420 multi-selection, so taking `Shift+L` now would have to be given back later.
3421 
3422 ### The playbar is attached to the bottom edge (since 2026-10-06)
3423 
3424 The playbar is a SHELF of the window's bottom edge, the full width
3425 (`(0, height - STATUS_H - playbar_shelf_h(), width, playbar_shelf_h())` in
3426 the floating layout), where it floated a gap in from the sides and the
3427 bottom like the other plates. Its height is `PLAYBAR_H` over the window's
3428 bottom lip (`playbar_shelf_h`, `PLAYBAR_H + bevel_width`): the shelf runs
3429 down INTO the lip, and only its top edge is a plate's.
3430 
3431 - **Drawn as part of the window's edge** (the PLAYBAR_IDX render arm): a
3432   plate turned inside out (cce-ui's `PaintCtx::frame`, see its CLAUDE.md),
3433   whose face is everything below the scene's opening and whose rolled edge
3434   runs round the opening's outline. So the top edge meets each side lip in
3435   a COVE — the opening's bottom corner, at `plate_corner_radius` in the
3436   DE's corner family — one outline with the top. The face runs out past
3437   the window's sides and bottom, so it has no other edge on screen, and
3438   the window's own lip (`State::append_window_lip`, the viewport's) is
3439   drawn again over the shelf and its coves, clipped to them, so the side
3440   lips run down unbroken into the bottom corners. For its first hours the
3441   bar was a plate rolled all round, its top corners rounded in from the
3442   side lips, the two rolls side by side at its ends and along its bottom;
3443   then for an hour its top edge ran straight into the side lips, crossing
3444   them, the two rolls stacked in a square at each end. The lip goes on
3445   after the transport (a quad between a plate and its carves drops them to
3446   the overlay shading); the transport's carves group into the frame.
3447 - **The transport stands clear of the lip**: `Playbar::frame` is the lip's
3448   width, and the widget lays its buttons, track and readout out in its
3449   rect less that on the left, right and bottom (`Playbar::content`). Zero
3450   in the legacy column layouts, which draw the old plate.
3451 
3452 What stands above it — the spreadsheet (`pb_off`, `playbar_shelf_h()`)
3453 and the params HUD — stops a gap short of its top, and the viewport's
3454 bottom-anchored text, a viewer state's line, stands
3455 on it (`State::scene_text_floor`) rather than on its transport.
3456 `the_playbar_is_attached_to_the_bottom_edge` is the test.
3457 
3458 ### Playback plays every frame (since 2026-10-06)
3459 
3460 `Playbar::tick` moves the playhead by `dt * fps`, but the SHOWN frame
3461 (the playhead rounded) by one at most: a tick that would carry it further
3462 lands on the next frame. So playback holds the rate while the frames keep
3463 up and slows to a frame a tick when they do not — a simulation too slow
3464 for its rate plays every step, slower, where until then the playhead kept
3465 to the clock and the frames between two ticks were never drawn (the solve
3466 still ran them; they were not seen). The loop is every frame of the
3467 range, frame k shown over k ± 0.5, so the step past the end is to the
3468 start in either direction; it was a loop of `end - start` that gave the
3469 end and the start half a frame each, and with the cap would have stepped
3470 over the start. With Repeat off, landing on the far end stops there.
3471 `playback_plays_every_frame_however_late_the_tick` is the test.
3472 
3473 What a replayed frame costs past the solve (measured 2026-10-06 on the
3474 user's project, 8k points, markers, two visualizers and the spreadsheet
3475 on): the spreadsheet's refill and the point markers were most of it, and
3476 both got cheaper without changing what they show — `points_vertices`
3477 works the marker sphere out once and moves it to each point (and then
3478 the markers were instanced, below). A replayed frame went from 140 ms to
3479 24 there. The visualizers were most of what was left: see "Attribute
3480 visualizers".
3481 
3482 **The spreadsheet is columns of values** (2026-10-07, cce-ui's
3483 `SheetColumn` and `set_spreadsheet_columns`): `geometry_to_spreadsheet_columns`
3484 copies each attribute's values into a typed column (`Int` for the point
3485 number, the groups and int attributes; `Float` at four decimals for the
3486 rest; a detail attribute repeated down its column), and the widget writes
3487 the cells it PAINTS — thirty rows, not ten thousand — and sorts by value.
3488 Until then every cell of every row was a `String` formatted on every
3489 refill, which a playing simulation does every frame. With the disk-cache
3490 fix above, a refresh of the table on a selected simnet at 11k points went
3491 from about 9 ms to 1.1 (evaluation 0.37, the columns 0.7, the widget
3492 0.01); the cells read as they did (`{:.4}`, the same strings).
3493 
3494 **A replayed frame, profiled whole** (2026-10-07, the user's project,
3495 point markers, wireframe and two visualizers on, frames 120–240 replayed
3496 from the cache): the CPU half was 11 ms a frame at 33k points and 20 at
3497 57k, the wireframe's edge list most of it — every frame's scene is a
3498 fresh copy, whose topology is not kept, so the wire pass built a whole
3499 topology (point→prims, neighbours and edges, the edges by one sort of
3500 every edge) to draw its edges, and looked each end's colour up by name.
3501 `Detail::edge_list` builds the edges alone unless the topology is
3502 already built, `unique_edges` sorts them by bucket rather than as one
3503 list (the same edges in the same order,
3504 `unique_edges_match_a_sort_of_every_edge`; it also builds every
3505 topology's edges, so the solvers' too), and `Detail::point_colors` reads
3506 the colour column once, for the wire pass and `triangulate`'s fill. The
3507 CPU half is now 6 ms at 33k and 13 at 57k: the wire edges 4, the graph's
3508 evaluation (the simnet's cached state copied out) 2.5, the fill 2, the
3509 point markers' instances 1.6, the 2D frame 1.5. In a shadow session the
3510 markers cost nothing on the GPU, being instanced; frames are paced to
3511 the display, so a frame of 8 ms CPU shows on the next 16.7 ms, and one
3512 over shows on the one after.
3513 
3514 **A copy of a mesh shares its topology** (the same day, `Clone for
3515 Detail`, an `Arc`): it was dropped by every clone, so every frame's scene
3516 — a copy of the cached simulation state, merged into an empty detail —
3517 built a topology again for the wire pass. Every structural writer of a
3518 `Detail` drops it (`invalidate`), and moving points or writing attributes
3519 leaves a topology that is still true, so a shared one is never stale.
3520 `merge` into an empty detail keeps the merged one's, the solve builds the
3521 topology of each frame it stores (about 1% of a step; counted in the
3522 checkpoints' budget, `Detail::topology_bytes`), and so a replayed scene's
3523 edges cost nothing; `scene_edge_verts` writes the wire vertices a piece
3524 of edges a thread into one buffer. The wire edges at 57k points: 4.9 ms
3525 a frame to 0.6. `a_copy_shares_the_topology_until_it_is_edited`,
3526 `the_wire_vertices_are_the_edges_in_order`.
3527 
3528 **A replayed frame copies the mesh once** (the same day). It copied the
3529 cached simulation state about seven times: out of its checkpoint (the
3530 state and what its last substep consumed), into the cache entry, into a
3531 checkpoint that already held the frame, and twice more as the scene walk
3532 merged it into an empty detail at the geometry node's level and at the
3533 root's. The solve's stored states are `Arc<Detail>` now (`SimSolve`,
3534 `Checkpoint`), handed out by reference count and copied once, for what
3535 the resolver returns, and `Detail::merge_owned` — what the scene walk
3536 merges with — takes a detail's arrays rather than copying them when it
3537 merges into an empty one, giving `merge`'s result to the last identity
3538 (`merge_owned_is_merge`). The graph's evaluation at 57k points: 2.7 ms a
3539 frame to 0.5.
3540 
3541 **The viewport's meshes, rebuilt every frame of a replay** (the same day):
3542 `geometry::detail_vertices` — the fill — writes its triangles a stretch of
3543 primitives a thread into one buffer, each primitive's place worked out by
3544 a first pass (`triangulate`'s vertices, bit for bit:
3545 `detail_vertices_are_the_triangulation`); `vis_marker_vertices` builds a
3546 piece of points a thread, joined in order; and `marker_instances` checks
3547 for coincident points with a plain multiplicative hasher
3548 (`geometry::QuickHash`) in place of the default one. At 57k points
3549 `present_scene` went from 6.5 ms a frame to 4.7: the fill 2.2 to 0.9, the
3550 point markers' instances 1.4 to 1.1, the wire 1.0 to 0.8; the vector
3551 markers (1.0) and the visualizers' copy and apply (1.0) are what is left,
3552 with the point markers' duplicate check (0.8 of their 1.1). A fresh 8 MB
3553 buffer a frame costs 0.2 ms, so reusing buffers was not worth it.
3554 
3555 **The spreadsheet's refill, every frame of a replay with it shown**
3556 (the same day): `geometry_to_spreadsheet_columns` read each point's colour
3557 and each group's membership by name (`color(p)`, `in_group(g, p)`); it
3558 reads the columns once now (`point_colors`, `AttribStore::group`). And
3559 cce-ui's `SheetColumn::max_chars`, the width pass the widget runs over
3560 every value of every column, is branch-free and spread over the columns
3561 on several threads for a large table. A refill with a simnet selected at
3562 57k points: 3.0 ms a frame to 1.6 — the evaluation of the selected node,
3563 which copies its state out of the cache, the 0.6 left.
3564 
3565 **The markers are instanced** (the same day, cce-ui's
3566 `SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
3567 Markers, the selected group's, the marked groups' and the spreadsheet
3568 rows' — is one white `geometry::marker_sphere` (240 vertices) drawn over
3569 a list of instances, a marker's place and colour
3570 (`geometry::marker_instances`), where each marker was the sphere copied
3571 to its point and the whole list uploaded every frame (46 MB at ten
3572 thousand points; 240 KB instanced). The spheres are three meshes —
3573 the group markers' at Group Marker Size, shared by the selected group,
3574 the marked groups and the rows; the points' at Point Marker Size; the
3575 vertices' at `VERTEX_MARKER_SCALE` of it — re-uploaded by the flush when
3576 a size moves (`State::marker_sphere_radii`), so a size slider uploads 240
3577 vertices and builds nothing else. `State::drawn_markers` (tests) expands
3578 instances over their sphere as the shader does;
3579 `instanced_markers_draw_what_the_copied_spheres_drew` holds that to the
3580 old per-point copies bit for bit, and shadow captures of the old and new
3581 builds with point markers and with vertex markers on differ in no pixel.
3582 
3583 ### The playbar shows what is cached, and what is stale (since 2026-10-06)
3584 
3585 A strip along the foot of the playbar's track says what the simulations
3586 hold: the accent where frames are CACHED, amber (the bypass tint) where
3587 they are STALE, nothing where they are not held. `SimCache::solved` reads
3588 each simnet's solve onto the timeline (`SolvedRange`): every frame from
3589 its start up to the furthest one in hand or kept as a checkpoint is
3590 cached, since the latest state and the checkpoints put any of them within
3591 an interval's steps; the frames up to an edit the solve went on across
3592 (`SimSolve::edited_at` — see "An edit is in from the next frame") are
3593 stale. `app::playbar_cache_runs` combines the simnets frame by frame —
3594 a frame is held when every simnet in the tree holds it (at or before its
3595 start it holds its seed), stale when any holds it stale — and runs it
3596 together; `State::sync_playbar_cache` hands the runs to
3597 `Playbar::cache` from the tick, worked out again only when the cache's
3598 `revision`, the geometry version or the frame range moves. A solve whose
3599 simnet has been edited since it ran is stale WHOLE: the simnet's
3600 `chain_hash` (its subtree, the half of the solve key that needs no
3601 evaluation) differs from the solve's, which is the case of a simnet
3602 nothing on screen reads, so the edit is in the tree and not yet solved.
3603 An upstream edit to an unsolved simnet's SEED is not seen that way (the
3604 seed needs an evaluation); the disk cache is not shown. A solve of a
3605 simnet deleted since counts for nothing.
3606 
3607 So: play to 60, cyan 1–60; edit the chain, amber 2–60 (the seed frame is
3608 the seed); play on to 100, cyan 61–100 after it; scrub back, and the
3609 mixed solve begins again from the seed — cyan up to where it stands.
3610 `the_playbar_cache_runs_say_what_is_cached_and_what_is_stale` is the
3611 rule, `the_playbar_shows_the_cached_and_the_stale_frames` a simnet
3612 played, edited and scrubbed. Checked in a shadow session.
3613 
3614 ### The playbar's right-click menu
3615 
3616 A right press on the playbar's plate (`over_playbar`) opens the sixth
3617 `context_menu` consumer (2026-09-30), the viewport menu's shape:
3618 `playbar_menu_rows` / `handle_playbar_menu_click` /
3619 `run_playbar_menu_action`, `PlaybarMenuAction`. Rows: the transport's
3620 commands by id (Play / Pause, Play / Pause Reverse, Go To Start Frame),
3621 the **Repeat Playback** and **Show Step Buttons** switches with their
3622 marks, then three slider rows —
3623 **Playback Rate** (1–120 fps by one), **Start Frame** (1–999) and **End
3624 Frame** (2–1000). An end moved past the other carries it a frame ahead,
3625 and the playhead is kept inside the range. The rate is a setting,
3626 `playbar_fps` in state.kdl beside `playbar_repeat`, saved on a wheel
3627 notch or a drag's release; the range is the PROJECT's —
3628 `ProjectViewState::frame_range`, keyed into `pane_layout_json` so it
3629 dirties the title, absent in an older save which keeps the live range.
3630 The four slider hooks in `handle_event` ask `slider_menu_open` and drain
3631 through `drain_menu_slider`, so the viewport's and the playbar's sliders
3632 share them without either being named there. `get_state` reports `fps`
3633 in its `playbar` block. `the_playbar_menu_sets_the_rate_and_the_range`
3634 drives it by pointer.
3635 
3636 **The step buttons** (since 2026-10-01): Previous Frame and Next Frame
3637 stand either side of the play button, each a whole frame off
3638 the ROUNDED frame and inside the range, without pausing —
3639 `Playbar::step`, which the Left / Right chords (`frame_prev` /
3640 `frame_next`) share. Show Step Buttons (`toggle_playbar_step_buttons`,
3641 unbound, a switch in the palette too) takes them away and the track
3642 widens into their room; on by default, persisted as top-level
3643 `playbar_step_buttons` in state.kdl beside `playbar_repeat`, and reported
3644 as `step_buttons` in `get_state`'s playbar block.
3645 `the_playbar_step_buttons_step_and_can_be_hidden` is the test. The
3646 transport's symbols are cce-icons glyphs (`play` / `pause`,
3647 `step-back` / `step-forward`, since 2026-10-05) drawn through
3648 `PaintCtx::icon` on a square half the button's side; they were a
3649 triangle of vectors and bars built from quads.
3650 
3651 ### Display mode: the viewport menu, and smooth shading
3652 
3653 The viewport's right-click menu has two PAGES (since 2026-09-29, as
3654 flyout submenus until 2026-10-02 — see "Page rows";
3655 until then it was one list of some twenty rows). The menu itself holds
3656 what is done — Frame All, View 1:1 — the guides (Show Grid, Show Origin,
3657 and since 2026-09-29 Show Camera Pivot with a Camera Pivot Size slider
3658 under it, 0–1 by a twentieth (the palette row is the coarse one, whole
3659 tenths up to 5; the size is the LENGTH of the marker's beams, whose
3660 thickness is fixed), which until then were in the Guides
3661 menubar and the palette only;
3662 the reference CUBE guide was removed on 2026-09-25 — its command, mesh,
3663 RT-scene copy, settings field and menubar item, with the Guides menubar
3664 addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto another's
3665 action, while old files carrying `show_cube_enabled` still load), and a
3666 row for each page: **Style** (how the geometry is drawn: the
3667 wireframe's switch, thickness and opacity, then the surface's shading,
3668 opacity and Show Occluded) and **Markers** (what is drawn on it: Group
3669 Marker Size and Pull Arrow Scale; then the
3670 overlays a class at a time — Show Point Markers and its size, Show Point
3671 Numbers, Show Point Normals; Show Primitive Numbers, Show Primitive
3672 Normals; Show Vertex Markers, Show Vertex Numbers, Show Vertex Normals).
3673 
3674 **The pages are turned to in place** (see "Page rows"):
3675 `viewport_menu_rows_of(page)` is the rows of the menu (`None`) or of a
3676 page, `show_viewport_menu_page` puts either up — at the pointer, or at the
3677 corner of the plate it replaces with a back band to the menu — and
3678 `State::viewport_menu_page` says which is up. `viewport_menu_actions` is
3679 always the shown rows' actions, so the slider hooks and
3680 `drain_viewport_menu_slider` need nothing per page. History: on
3681 2026-09-29 the two were pages of the one popup with a Back row, then the
3682 same day flyout submenus (cce-ui's, retired with this), then pages again.
3683 
3684 **The primitive and vertex overlays** (`toggle_prim_numbers`,
3685 `toggle_prim_normals`, `toggle_vertex_numbers`, the same day) are
3686 collected by `render::scene_element_overlays` off the scene `Detail` the
3687 point overlays read: a primitive's number at its centroid, its normal
3688 (Newell's, so a quad not quite flat has one) a whisker from there, and a
3689 vertex's number — its index in the detail — inset `VERTEX_LABEL_INSET` of
3690 the way from its point toward its primitive's centroid, so the vertices
3691 sharing a point stand apart, each inside its own primitive. Warm for
3692 primitives, green for vertices, the points' pale blue unchanged. They are
3693 persisted beside the point overlays in `ViewportSettings`, capped at 2000
3694 labels a class, and dimmed by the fill in front of them as the point
3695 numbers are (one `point_transmittance` call over all three lists).
3696 `primitives_and_vertices_are_numbered_where_they_are` is the test.
3697 
3698 **Vertex markers and vertex normals** (`toggle_vertex_markers`,
3699 `toggle_vertex_normals`, later the same day) stand where the vertex's
3700 number does, so all three of a vertex's overlays name one place. The
3701 markers are the point markers' spheres at `VERTEX_MARKER_SCALE` (0.6) of
3702 Point Marker Size, in the vertex green, smaller so a point's marker is not
3703 lost among the markers of the vertices around it; they share the point
3704 markers' mesh, and `rebuild_overlay_markers` builds both lists, so
3705 the size slider re-sizes both without an evaluation. **A vertex's normal
3706 is its `N` attribute where the detail carries a Float3 one on its
3707 vertices, and its primitive's normal where it does not** — the normal of
3708 this corner of this face, where a point's is the average over the faces
3709 around it. Without the attribute the whiskers show the faceting: the
3710 corners of one face agree, and a shared point wears a fan of them. `scene_element_overlays` takes an `ElementOverlays`
3711 of what is wanted and returns an `ElementOverlayGeometry`.
3712 
3713 `the_viewport_menu_groups_its_display_rows` holds the
3714 order of all three. The rows: the Show Wireframe switch (its registry command), **Flat
3715 Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
3716 a **Wire Thickness** slider under the wireframe switch (1–8 px by
3717 half a pixel, the palette row's range), a **Wire Opacity** slider under
3718 that (percent by 5, `State::wire_opacity` — the wires' own, apart from the
3719 polygons' Opacity; until 2026-09-25 it was the Wire Color's ALPHA, and
3720 `StoredRenderSettings` moves an old alpha, from state.kdl's `#rrggbbaa` or
3721 a project's four-component array, into it on load), a
3722 **Point Marker Size** slider (0.005–0.1 world units, the palette row's
3723 since 2026-09-29 — until then that row was a spin in THOUSANDTHS, so the
3724 two marker sizes read as different numbers for one radius, and 0.025
3725 typed into it landed on the spin's floor; no suffix since
3726 the World Unit names the units), a **Group
3727 Marker Size** slider (0–0.2 world units by 0.005), and the
3728 polygon **Opacity** as a
3729 SLIDER row — cce-ui's `context_menu::MenuSlider` (2026-09-25), set on the
3730 shown menu by `open_viewport_context_menu`. `viewport_menu_slider` is the
3731 one table of the menu's sliders (read from the live value) and
3732 `land_viewport_menu_slider` writes each back, so another slider row is a
3733 row in `viewport_menu_rows` plus an arm in each. The wheel over Opacity steps 5% and saves; a press
3734 on its band jumps and drags (a slider row never closes the menu), landing
3735 the value live and committing on the release. The designer dispatches the
3736 menu itself, so four hooks carry it: `slider_press` ahead of the row action
3737 in `handle_viewport_menu_click`, `slider_dragging` in CursorMoved (after
3738 `cursor_moved`, which moves the held value), `slider_release` at the top of
3739 MouseInput, and `mouse_wheel` at the top of MouseWheel — where a wheel
3740 anywhere over the open menu is swallowed rather than orbiting the scene.
3741 `drain_viewport_menu_slider` lands a change through
3742 `land_viewport_menu_slider`, which sets the live field and redoes only what
3743 it feeds: opacity and wire thickness are draw-time, and group marker size
3744 re-bakes the Selected-Group
3745 markers — re-sized from `State::group_members`, the positions `sync_nodes`
3746 keeps from its evaluation, by `rebuild_group_markers`; point marker
3747 size re-sizes the Show Point Markers overlay from the scene positions
3748 `rebuild_scene_geometry` keeps while it is on (`overlay_marker_points`,
3749 `rebuild_overlay_markers`). None of it
3750 re-evaluates the graph, which `apply_setting`'s regenerate pass would do per
3751 pixel of drag. `sync_nodes` also re-sizes the markers when only the size
3752 moved (`last_group_marker_size`), so the palette's Group
3753 Marker Size row reaches them the same way. `viewport_menu_rows`
3754 and `run_viewport_menu_action` are split from the open and the click so a
3755 test reads and runs the rows.
3756 
3757 **There is one display of a marker on every point, Show Point Markers**
3758 (since 2026-09-29). Until then there was a second, **Show Points**
3759 (`toggle_render_points`, with Point Size and Point Color), which the
3760 retired Render node had brought: the same small sphere on the same points
3761 of the same scene, with a size and a colour of its own, differing only in
3762 following the fill's Opacity. It is gone — the command, the mesh, the three
3763 settings. The group markers were sized off it, Point Size times Group
3764 Marker Scale, and have a size of their own now
3765 (`State::group_marker_size`, world units); `StoredRenderSettings` reads a
3766 file from before by multiplying the old pair out, and does not read
3767 `render_points` or `point_color`.
3768 `an_older_render_block_gives_the_group_markers_their_size` is the test.
3769 
3770 **Smooth shading is baked, not shaded.** The raster pass flat-shades every
3771 fill in `scene3d.wgsl` from screen-space derivative normals, and cce-ui's
3772 `Vertex3D` carries no normal. The light is fixed in WORLD space, though, so
3773 lighting each vertex from its smooth point normal and interpolating is
3774 exact: `geometry::smooth_lit_vertices` multiplies each corner's colour by
3775 `shade_factor(point_normals[p])`, and the fill draws with
3776 `SceneDraw::prelit` (cce-ui, 2026-09-24) so the shader does not shade it
3777 twice. `shade_factor` has to agree with the shader about which side is
3778 lit, and since 2026-10-02 both take the environment's sun as the direction
3779 TOWARD the light: the shader's derivative normal is screen-right ×
3780 framebuffer-DOWN, which points INTO a visible surface, so it dots `-n`
3781 with the light where the bake dots the outward normal; on a plane the two
3782 modes give identical brightness
3783 (`smooth_shading_bakes_the_flat_shaders_light_per_vertex`). Before that
3784 date both dotted the INWARD normal with a constant `(-0.55, 0.45, 0.7)` —
3785 a light from below, read the right way round. See "The Environment
3786 node". The
3787 lit copy is `State::scene_smooth_verts`, kept only while smooth is on; the
3788 path tracer keeps reading the unlit `rt_sphere_verts`, whose colours are
3789 its materials. Smoothing follows topology, so a welded mesh rounds off and
3790 a soup of unshared triangles stays faceted. Persisted as
3791 `render.smooth_shading` in state.kdl.
3792 
3793 **Show Occluded draws a translucent fill see-through** (`toggle_show_occluded`,
3794 a viewport-menu row under the opacity presets; `render.show_occluded`). In
3795 effect only below full opacity (`State::see_through_active`) — at 100% the
3796 ordinary fill is exact and cheaper, and the toggle says so on the status
3797 line rather than doing nothing silently. The fill then draws through cce-ui's
3798 `SceneDraw::see_through` pipeline (2026-09-25): no face culling, so a closed
3799 mesh shows its far wall, and no depth WRITES, so its near layers hide
3800 neither its far ones. The depth TEST stays on, so what is drawn before the
3801 fill (grid, points, markers) still occludes it.
3802 
3803 **The wires go BEFORE a see-through fill, and write depth.** Until
3804 2026-09-25 they drew after it as usual, reasoning that with nothing written
3805 nothing could hide them — which is exactly the bug: every far-side wire
3806 passed and painted OVER the near faces at full strength, so a translucent
3807 sphere read as if its back lattice sat in front of the camera-facing prims.
3808 The wire draw now carries `see_through` too, which selects cce-ui's
3809 depth-writing wire pipeline, and is submitted first; each fill layer then
3810 lands only where it is nearer than the wire under it. A far wire is dimmed
3811 by exactly the layers in front of it, a near wire by none (the fill's
3812 `wire_base_width` offset puts it ahead of its own face), for any mesh
3813 shape, convex or not. The one cost is with a translucent WIRE colour: the
3814 fill layers behind a wire are not drawn under it, so the wire blends over
3815 what is behind the whole mesh rather than over the far fill.
3816 Blending without depth writes is in submission order, so the stage pass
3817 re-sorts the fill's triangles FARTHEST FIRST from the eye
3818 (`geometry::sort_triangles_back_to_front`, centroid distance — painter's
3819 order, exact for non-intersecting triangles and close for the rest) and
3820 re-uploads them whenever the geometry version, the shading or the eye moves
3821 (`State::sorted_fill_key`), which during an orbit is every frame. The eye
3822 is taken in MESH space, the inverse of view × model. Leaving see-through
3823 clears the key and re-uploads nothing: sorted order is still a valid
3824 opaque mesh.
3825 
3826 **Every annotation is dimmed by what is in front of it** (since
3827 2026-09-29). Three mechanisms, because there are three kinds of annotation.
3828 The MARKERS (Selected-Group markers, Show Point Markers)
3829 always were: they draw before the fill and the wires and write depth, so a
3830 nearer translucent face or wire blends over them. The LINE annotations —
3831 the normal whiskers, Visualize's vectors, the pull arrows — drew AFTER the
3832 fill until then, which was wrong both ways: behind a translucent fill that
3833 writes depth they were hidden outright, and behind a see-through one, which
3834 writes none, the far side's painted over the near faces at full strength
3835 (the wires' own bug of 2026-09-25). They go before the geometry now, on the
3836 depth-writing line pipeline (`see_through: true` on a wire draw selects
3837 it). The point NUMBERS are 2D text and never meet the depth buffer, so
3838 their dimming is worked out on the CPU: `geometry::point_transmittance`
3839 counts the fill layers the sight line to each point crosses — every
3840 triangle when seen through; otherwise the front-facing ones nearer than all
3841 drawn before them, which is what culling and the depth write leave — and
3842 the label's alpha is `(1 - Opacity)` to that power. Triangles are binned by
3843 screen bounds, so the cost is a projection of the mesh per camera move and
3844 a few tests per label. `State::sync_point_number_alpha` runs it from the
3845 stage pass, for the eye being staged; a label under 2% is not drawn, so
3846 behind an OPAQUE face a number is hidden, where until then every number
3847 showed through everything. The wires are not counted against a number: a
3848 line a pixel wide is not in front of a label in any way one alpha could
3849 show. **A number under a plate is not drawn** (`State::under_a_plate`,
3850 `point_number_labels`, since 2026-09-29). A blur-behind plate samples the
3851 frame so far, which is geometry; the renderer draws ALL text in one pass
3852 after every batch, so a number under a plate stood over it, sharp, where
3853 the markers and wires beside it showed through frosted. Blurring it with
3854 the scene needs a text layer drawn ahead of the plates, which cce-ui does
3855 not have; under the pane tint a blurred 10 px number would not be read in
3856 any case. `a_point_number_under_a_plate_is_not_drawn` is the test.
3857 
3858 **What the 2D frame draws over the scene is placed by the camera as it
3859 is when the frame is painted** (`State::refresh_scene_view`, at the top of
3860 `collect_display_list`, since 2026-09-29). The runner paints the 2D frame
3861 and THEN stages the scene, and the stage pass was the one place
3862 `last_scene_mvp`, the pane's rect and the eye were set — so the numbers
3863 and a viewer state's handles were placed by the camera
3864 of the frame before. They trailed the geometry and its markers, which
3865 are meshes drawn by the frame's own matrix, by a frame whenever the
3866 camera moved, and stood a frame's move off their points once it stopped.
3867 `State::scene_view` is the pane and the view as they are now, for both.
3868 The dimming is asked for twice a frame that way and worked out once
3869 (`number_alpha_key`). Not in the traced mode, which keeps the view the
3870 raster pass last staged. `the_point_numbers_are_placed_by_the_camera_as_it_is`
3871 is the test.
3872 
3873 **A scene rebuild works the dimming out itself**, for the view the
3874 scene was last staged from (`last_scene_mvp`, `last_scene_eye`). The 2D
3875 frame is painted BEFORE the stage pass, so a rebuild that only cleared
3876 the alphas drew one frame of every number at full strength; a playing
3877 simulation rebuilds at every frame, and the numbers flickered
3878 (`a_scene_rebuild_keeps_the_point_numbers_dimmed`).
3879 `a_point_number_is_dimmed_by_the_fill_in_front_of_it` is the test;
3880 the whiskers' order has none, being a draw list only a renderer reads, and
3881 was checked in a shadow session before and after.
3882 
3883 ### Attribute visualizers
3884 
3885 `src/visualizer.rs` (since 2026-10-01) is Houdini's viewport visualizers:
3886 a point attribute of whatever the viewport displays, coloured through a
3887 ramp or drawn as a line from each point, with NO node in the graph. A
3888 `Visualizer` is the Visualize node's settings under the node's own names
3889 and options (Attribute, Mode, Ramp, Range, Manual Range, Blend, Opacity,
3890 Scale, Group) plus a switch, and it runs through `geometry::apply_visualize`
3891 over a node built from them (`Visualizer::as_node`), so a visualizer and a
3892 Visualize node cannot disagree about what they draw
3893 (`a_visualizer_reads_as_the_visualize_node_does`). Several apply in order,
3894 the later over the earlier, as a chain of Visualize nodes composites.
3895 
3896 - **They are display settings**, by the rule "There are no meta nodes"
3897   states: `State::visualizers`, persisted in the viewport block of
3898   state.kdl and with the project's display block, so a project keeps its
3899   own. **As one string** (`visualizer::encode` / `decode`: `key=value`
3900   joined by `|`, a visualizer per `;`, percent-escaped) — not JSON,
3901   because when this landed cce-ui's `json_to_kdl_string` wrote a string
3902   between quotes WITHOUT escaping the ones inside it: a JSON string came
3903   back as a line no parser reads, and a settings file that fails to parse
3904   is read as the DEFAULTS. cce-ui escapes since the same day (`kdl_quote`),
3905   but the encoding stays: it is what state.kdl files already hold, and it
3906   reads plainly there. A key the reader does not know is skipped.
3907 - **They are applied to the scene, not evaluated with it.**
3908   `rebuild_scene_geometry` evaluates the graph, keeps the result as
3909   `State::scene_base` with its attributes and ranges
3910   (`State::scene_attributes`), and hands a copy to `present_scene`, which
3911   applies the visualizers and does everything the viewport draws of a
3912   scene — the fill, the traced copy, the groups, the overlays, the edges.
3913   An edit to a visualizer runs `revisualize`, which presents `scene_base`
3914   again: no graph evaluation, so a dragged slider costs a re-mesh. A
3915   visualizer naming an attribute the scene lacks draws nothing and says
3916   "not in the scene" in the list; it is kept, since the scene it was made
3917   for may come back. Not in `--thumbnail` or `--export`, which have no
3918   display settings.
3919 - **They are edited in the params HUD** (since 2026-10-06; until then in
3920   the dialog, as its Visualizers and VisualizerEdit modes, which are gone
3921   with the dialog's two-slider `Float2` control that only the Manual Range
3922   used). The `attribute_visualizers` command — the palette's row, which
3923   closes the palette, and a plain row of the viewport menu — sets
3924   `State::vis_hud`, and the HUD shows, in place of the selected node's
3925   parameters: a **Visualizer** dropdown picking the one edited (`#1 uv`),
3926   **Add Visualizer** and **Delete Visualizer**, its settings — Enabled,
3927   Attribute and Group as dropdowns over the scene's attributes and
3928   groups, Mode, then Ramp, Range, a Manual Range `float2`, Blend and
3929   Opacity, or Vector's Scale — and **Done**. The rows are a PSEUDO-NODE's
3930   parameters (`visualizer_hud_params`), so `param_display`, the
3931   `show_when` conditions, the separators and the controls are the HUD's
3932   own; the write-back (`sync_visualizer_hud_back`, ahead of the node path
3933   in `sync_parameters_to_project`) turns each changed row into the edit it
3934   names, re-reading the rows when they change shape (another visualizer,
3935   Mode, Range, Attribute, Add, Delete) and not during a slider drag, which
3936   would drop the slider held. state.kdl is written at the frame
3937   (`settings_save_pending`). Done hands the HUD back, and so does picking
3938   another node (`vis_hud_from`, the node the HUD would have shown when it
3939   opened). A new visualizer starts on the scene's first attribute that is
3940   not `P`, `Cd` or `N`.
3941 
3942 `attribute_visualizers_are_edited_in_the_params_hud` drives the rows end
3943 to end.
3944 
3945 **What a visualizer costs a frame** (2026-10-07). `apply_visualize`, which
3946 the node and every visualizer run, was QUADRATIC in Vector mode: the
3947 points it touches were a list of every point, searched once a point —
3948 some 60 million comparisons a frame at 11k points — and every value, in
3949 both modes, went through a lookup by name. It reads a group mask by index
3950 and the attribute and the colours as whole columns now, writing the
3951 colours back in one pass, with the arithmetic unchanged and in the same
3952 order; the old function is kept under `cfg(test)` and
3953 `visualize_matches_its_reference` holds the two equal bit for bit across
3954 160 cases (both modes, groups, ranges, blends, a flat, an integer and the
3955 `Cd` attribute, no `Cd`, a `Cd` of four floats). `scene_attributes` and
3956 `vis_marker_vertices`, which run on every scene rebuild, read their
3957 columns once too. On the user's project (an `N` Vector and a `val2` Ramp)
3958 at 11.5k points: 55.6 ms a frame to 1.3, the markers 1.5 and the
3959 attribute ranges 0.7 left.
3960 
3961 ### The Normal node writes point or vertex normals
3962 
3963 `normal` has a **Class** row (since 2026-09-29): `Points`, what it always
3964 wrote, or `Vertices`, a normal per CORNER on the detail's vertex store,
3965 with a **Cusp Angle** (shown for Vertices, 0–180, default 60). It is a
3966 class of the one node and not a node of its own, for the reason
3967 `gem_graph` was not ported: two nodes that compute the same thing into two
3968 stores are one node with a choice. A node without the row is one from
3969 before it and writes the points'; the template merge gives a saved
3970 instance the row at `Points`.
3971 
3972 `geometry::vertex_normals(geom, cusp)` is the sum, for each corner, of the
3973 face normals around its point that lie within the cusp angle of its OWN
3974 face's — so faces that turn less than the angle from each other are
3975 averaged and read smooth, and those that turn more keep to their own side
3976 and the edge reads hard, which a point's one normal cannot say. At 180 it
3977 is `point_normals` term for term (the same unnormalized cross, so the two
3978 weigh faces alike); at 0 the face's alone.
3979 
3980 Two things read a Float3 `N` on the vertices (`own_vertex_normals`, which
3981 also asks that it is as long as the vertices are): the Show Vertex Normals
3982 overlay, and **smooth shading**, which lights each corner by its own
3983 normal where there is one (`smooth_lit_vertices`) and by its point's where
3984 there is not — so a cusp is visible in the fill, not only in the whiskers.
3985 Flat shading is the shader's and reads neither. The attribute rides the
3986 scene's merged `Detail` to both. `the_normal_node_writes_cusped_vertex_normals`
3987 is the test, on a box, whose faces meet at 90 degrees.
3988 
3989 ### Pull arrows
3990 
3991 While the params pane shows an Attribute node that writes `Pos`
3992 (`geometry::moves_points` — the simnet's `pull1` is one), the viewport draws
3993 amber arrows from where points were to where the node puts them. They are
3994 MEASURED, output `P` minus input `P` (`point_displacements`), not read off
3995 Value, so Set, Multiply and an expression all show what actually happened,
3996 and the arrowed points are exactly the ones that moved. An arrow's full length,
3997 head included, is the displacement times **Pull Arrow Scale**
3998 (`State::pull_arrow_scale`, default 1 — the true vector; a dialog row and a
3999 viewport-menu slider, 0.25–10x, persisted in the render block beside Group
4000 Marker Size). The scale is display only: the sampled pairs are kept
4001 unscaled on `pull_arrow_pairs`, and `rebuild_pull_arrow_verts` stretches
4002 each arrow from its fixed base, so a slider drag re-evaluates nothing.
4003 
4004 **Strength and Scale By scale the pull** (the Attribute node's Modify,
4005 since 2026-09-29). `Strength` is a `slider` over 0..2 with one in the
4006 middle (a `float` box for its first day), `Scale By` an `attribute`
4007 naming a point attribute whose value weighs each point; the amount that
4008 lands at a point is their product. They scale the EFFECT — the change the
4009 node makes, `old + (combined - old) * amount` — so they mean one thing
4010 under every Combine: an Add moves by that much of Value, a Set goes that
4011 far toward it, a Multiply that far toward the product. Two rows rather
4012 than a longer vector because the Value row is text holding three numbers
4013 and takes no expression per component, while Strength is a number: `$F /
4014 10` ramps a pull in (an expression is not clamped to the slider's range;
4015 a value set on the slider or typed into its readout is), and the trackball keeps the direction while one
4016 slider sets how hard. At an amount of exactly one the combined value is
4017 written as it always was, bit for bit, so a save from before the rows
4018 (which the template merge gives a Strength of 1) solves to the same
4019 numbers. A Scale By naming no attribute is an error on the node and moves
4020 nothing. The arrows are measured, so they show the scaled pull.
4021 
4022 **Composite broadcasts a single number** (since 2026-09-29): a Source B
4023 of ONE component is every component's, so a Float3 times a Float is the
4024 vector scaled — by a constant, or per point by a weight. The componentwise
4025 operations used to pair the number with X and zero with the rest, which
4026 kept X and zeroed Y and Z under Multiply and touched X alone under Add. A
4027 Source B of two or more components still pairs off by position; Dot,
4028 Distance and Length reduce to one number and did not change.
4029 
4030 **Composite writes a Result and folds up to four operands** (since
4031 2026-10-06). **Result** (an `attribute` row) names where the combination
4032 goes, created when it does not exist — as wide as the widest operand, Name's
4033 own type when Name is that wide (an Int stays an Int), one Float for Dot,
4034 Distance and Length; points outside Group get zero, as Create leaves them —
4035 and an existing one keeps its type. Empty is Name, in place, which is what
4036 every save from before has. Name is then the FIRST OPERAND, not the target.
4037 **Source C** and **Source D** fold in after B, left to right (`((Name op B)
4038 op C) op D`), for Add, Subtract, Multiply, Divide, Minimum, Maximum and
4039 Average, and are hidden and not read for the rest; an empty one is left
4040 out. Average is the mean of every operand given, not a pairwise fold. A
4041 Result of Pos / Col / P / Cd is refused: Modify writes those. Every operand
4042 is read before Result is written, since it may be one of them.
4043 `composite_point` is the per-point arithmetic, broadcasting a one-number
4044 operand (Name included) as above. **Length is the length of Name** (the
4045 same day; it was Source B's, Name unread) and reads no source, so Source B
4046 is hidden for it. **Format 7** (`Project::migrate_composite_length`) carries
4047 a save across: a Length node's Source B moves into Name and the attribute it
4048 wrote (Name, or Result when set) becomes Result, which, existing, keeps its
4049 type — the same numbers. A Length with no Source B failed before and is left
4050 alone. `composite_writes_a_result_and_folds_up_to_four_operands` and
4051 `a_saved_composite_length_keeps_its_result` are the tests.
4052 
4053 **Per Frame makes the amount a rate.** Inside a simnet the chain runs once
4054 per SUBSTEP, so a pull that lands whole each run pulls four times as far
4055 a frame at four substeps — the substep count, which is there to steady a
4056 solve, becomes its speed. With `Per Frame` on (the template's default)
4057 the node reads the solver's `dt` off the state and scales what
4058 ACCUMULATES: an Add lands `dt` of its amount, a Multiply the `dt`-th
4059 power of its factor, which is what compounds back to the factor over a
4060 frame. A Set does not accumulate and is left alone, and the row is shown
4061 only for the other two. Outside a simnet there is no `dt`. It is a
4062 switch rather than the rule because the other use of an Add is real: a
4063 chain that adds one to a counter to COUNT its runs
4064 (`test_substeps_run_the_chain_more_than_once_per_frame`) wants the step,
4065 not the frame. A node that does not carry the row reads off, which is
4066 every hand-built node; a saved instance gets the row, on, from the
4067 template merge — so an existing pull in a simnet with substeps above one
4068 moves a substep-count slower after this, which is the point.
4069 
4070 At most `State::PULL_ARROWS_MAX` (12) points get one, picked by farthest-point
4071 sampling (`spread_sample`) so they cover the region the pull covers rather
4072 than bunching wherever the point numbering runs locally. A node inside a
4073 simnet is measured as this frame's last substep saw it, with that substep's
4074 feedback pushed, the same rule the dived-in scene walk draws by: an arrow
4075 runs from where the point went INTO the pass that produced the displayed
4076 state, so its tip lands on the displayed point whenever the pull is the
4077 chain's last mover. Without the feedback the `input` node reads the seed,
4078 and the arrows would sit where the points started, not where they are. `sync_pull_arrows` borrows the shared sim cache for
4079 this, so during playback the feedback is a cache hit rather than a re-solve
4080 from the seed every frame; it runs from both `sync_nodes` and the end of
4081 `rebuild_scene_geometry`, keyed by (node id, params, geometry version).
4082 
4083 ### Dragging the scene orbits the camera
4084 
4085 `State::orbit_camera_by` turns the camera by a drag delta, armed by a left
4086 press that `cursor_in_viewport` says landed on scene. Before it the camera had
4087 NO drag gesture at all: `Viewport3D` handles only `MouseWheel`, so the scene
4088 turned by scrolling and by nothing else — which suits a trackpad and leaves a
4089 mouse with no way to look around.
4090 
4091 `ORBIT_RADIANS_PER_PX` is the trackpad's own pixel-delta constant, so a drag
4092 and a two-finger swipe turn the scene at the same rate rather than feeling like
4093 two different cameras. The default camera carries its orbit in
4094 `rotation_x`/`rotation_y`; a NAMED camera accumulates into
4095 `pending_yaw`/`pending_pitch` for its node to pick up — the same split the
4096 scroll path makes, so a dragged camera and a scrolled one mean the same thing.
4097 A drag stops when the pointer does (`reset_velocity`), unlike a flicked scroll,
4098 which coasts.
4099 
4100 **The scroll orbit and ctrl-scroll zoom coast** (since 2026-09-30):
4101 `Viewport3D` runs them on cce-ui's `ScrollMotion`, the model the network
4102 pan uses — a finger tracks 1:1, the lift coasts on the velocity of the
4103 finger's own events, a wheel notch glides. Until then it had a coast of
4104 its own that never ran: the runner delivers the lift as a ZERO delta in
4105 the `FingerEnd` phase, the viewport read that as more motion, and its
4106 per-frame velocity estimate blended zeros for the 50 ms it waited before
4107 calling the gesture over, so a flick stopped dead at the lift (measured in
4108 a shadow: 0.01 degree after the lift, against some 46 now). The motion's
4109 positions are accumulators in wheel px; what turns the camera is how far
4110 they moved (`orbit_by_px`, `zoom_by_px`), so the pitch clamp and a camera
4111 node's pending orbit work as before. `inertial_scroll` in config.kdl's
4112 `input.inertial` still switches the coast off; how long it runs is
4113 input.kdl's `scroll_friction`, as for every pane that coasts (the old
4114 `scroll_friction` field is gone). A trackpad orbit still locks to the axis
4115 it clearly favours, per gesture. A pinch does NOT coast — the runner keeps
4116 a pinch's end to itself — and stops a zoom that is coasting.
4117 `Viewport3D::wheel` takes the phase as an argument, so
4118 `a_trackpad_flick_coasts_the_viewport_after_the_lift` drives a flick
4119 without the runner's global.
4120 
4121 Precedence matters and is load-bearing. The press arms AFTER the viewer state's
4122 own press hook, so dragging a curve handle still edits it, and after the node
4123 hit tests, so a press on a node still moves the node. "Empty" means the scene
4124 really is what is under the cursor — which, with the network overlaying the
4125 window, is exactly what `in_network_pane`'s node test decides.
4126 
4127 **The camera PANS as well** (`State::pan_camera_by`, since 2026-09-29;
4128 until then the view only turned and zoomed about its pivot, and the
4129 pivot moved by Frame All alone). A pan slides the pivot and the eye
4130 together across the plane of the screen, so the view turns nowhere and
4131 what is on the PIVOT's plane follows the pointer px for px — the
4132 projection is a perspective, so what is nearer moves further. Three
4133 gestures, `pan_drag` beside `orbit_drag`:
4134 
4135 - **the middle button, dragged** over the scene. Armed after the
4136   network's own pan, which the middle button is wherever the network is
4137   laid out — the whole window, the network having no plate, so the
4138   camera has it only while the network is hidden or circular — and AHEAD of
4139   the press cascade's `button != Left && != Right` return, which is where
4140   the first cut put it and where it never ran;
4141 - **shift and the left button**, in the orbit's own arm, so a viewer
4142   state's handle and a node still take the press first;
4143 - **shift and a scroll**, at the top of the wheel arm, a notch being
4144   `PAN_PX_PER_LINE` px.
4145 
4146 The Default Camera's eye hangs off its pivot, so `Viewport3D::pivot` is
4147 all that moves. A camera node has its Pivot and Position rewritten, to
4148 four decimals; a pan is hundreds of small moves and each read back from
4149 the text would lose what the text could not hold, so `State::pan_exact`
4150 keeps what the last pan wrote in full and is used while the node still
4151 says it. `the_camera_pans_with_the_pointer` is the test.
4152 
4153 **The active camera's name lives in two places, and `State::set_active_camera`
4154 is the only writer of either.** The viewport widget keeps its own copy because
4155 its wheel handler routes by it — the Default Camera's orbit lands on the
4156 widget's `rotation_x`/`rotation_y`, a camera node's accumulates into
4157 `pending_yaw`/`pending_pitch` for `tick_frame` to write onto the node — and
4158 until 2026-09-24 that copy was written once, at construction, from whichever
4159 project `State::new` loaded. Open a project whose active camera differed (the
4160 startup default-project pointer, Open, New, the viewport menu) and the two
4161 disagreed: the widget parked every wheel into the pending pair, the drain saw
4162 the Default Camera active and discarded it, and trackpad scrolling in the
4163 viewport did nothing while a drag — which reads `State`'s copy — still orbited.
4164 `a_wheel_orbits_the_camera_that_is_active_after_a_change` covers the three
4165 paths.
4166 
4167 ### Commands, chords and the palette
4168 
4169 `src/command.rs` is one list of everything the app can be asked to do. Each row
4170 carries its `id` (snake_case — this is what `input.kdl` binds, so it follows
4171 that file's existing convention and must not change when the label does), its
4172 `label` (what the palette and menus show), a `Context` (which pane it belongs
4173 to), a `Run` (how it reaches the work), and a `default_chord`.
4174 
4175 Before it there were three vocabularies with nothing holding them together: the
4176 `Action` enum matched against chords, the menu-item LABELS `execute_menu_action`
4177 dispatches on, and a hand-written registration block listing which `Action` got
4178 which chord. A command lived in whichever of them someone had needed, and
4179 nothing could tell you which ones had no binding at all. `ShortcutManager` now
4180 binds **command ids**, not `Action`s — which is also what lets a chord reach a
4181 menu-dispatched command like Open, something no binding could do before.
4182 
4183 `Run` has two variants because the app genuinely has two dispatch paths; a
4184 command names exactly one, so the palette, the chord and the menu all end up in
4185 the same code. `State::run_command(id)` is the single entry point, and it is
4186 exposed over MCP as `run_command` — every command is scriptable, including the
4187 ones no menu label reaches.
4188 
4189 **The toolkit's runner claims four chords before the app sees them**, from
4190 `input.kdl`'s `cce-ui` domain: `undo` (ctrl+z), `redo` (ctrl+shift+z),
4191 `focus_next_group` (ctrl+tab) and `focus_prev_group` (ctrl+shift+tab). Undo and
4192 Redo are therefore registry rows with NO default chord — not an oversight: the
4193 runner routes them to the focused widget first, so a text box undoes its own
4194 typing before the app is asked, and registering ctrl+z here would quietly take
4195 that away. Focus Next/Previous Pane do override the runner's group chords, which
4196 is deliberate and predates the registry. `command::conflicts` cannot see any of
4197 this — it compares this app's bindings with each other — so it is written down
4198 here instead.
4199 
4200 `conflicts()` reports two commands resolving to one chord at startup, because
4201 the failure is otherwise silent and looks like a broken command rather than a
4202 broken binding: `match_command` returns the first match and the second simply
4203 never runs. It compares chords as PARSED, not as text. That exposed a real bug:
4204 `Shortcut`'s derived `PartialEq` compared character keys byte for byte while
4205 `matches()` compared them case-insensitively, so `Ctrl+S` and `Ctrl+s` were one
4206 keypress at the keyboard and two distinct values in memory — and the collision
4207 detector quietly failed to report exactly the collision it exists to catch. Both
4208 now go through one `same_key`.
4209 
4210 **The palette** is the dialog's Commands half (`src/dialog.rs`, below), not a
4211 widget of its own. Ranking is `fuzzy_rank`, which reproduces the plugin's
4212 fuzzyfinder exactly — shortest contiguous span, then earliest start, then
4213 alphabetical — so muscle memory survives; the focused pane's commands are then
4214 partitioned to the front, stably, without dropping anything (a palette that
4215 hides what you are looking for is worse than one that lists it second). Each row
4216 carries its chord in a column of its own, so the palette teaches the keyboard
4217 rather than replacing it.
4218 
4219 It was a `cce-cloud --dmenu` popup until 2026-09-19: a second PROCESS with its
4220 own window, handed one line of text per row on stdin and answering with one line
4221 on stdout. Everything awkward about it followed from that pipe — the chord had
4222 to be padded into the label to fake a column (a tab rendered as one literal
4223 stop, so they came out ragged), and the answer had to be matched back to a
4224 command by the LONGEST label the row starts with, since "Save" is a prefix of
4225 "Save As"'s row. `palette_row` / `from_palette_row` were that encode/decode pair
4226 and are gone with it; `fuzzy_rank` and `palette_entries` survive, because the
4227 ranking was never the problem.
4228 
4229 `command_palette` (Ctrl+P) and `toggle_dialog` (Alt+D) both reach the same
4230 dialog and differ in exactly one way, which is the reason both rows exist:
4231 Ctrl+P OPENS it (with a fresh query, never closing), Alt+D toggles it.
4232 
4233 `test_every_menu_command_names_a_label_that_is_dispatched` scans `app.rs` for
4234 `execute_menu_action`'s arms. Scanning source is an odd way to assert it, but
4235 the alternative is calling every command to see whether it is handled, and
4236 "Exit" would end the test run. It is the check the plugin's `hccommands.py` doc
4237 argues for: a label kept in two places drifts, and a renamed one fails silently
4238 — the dispatch falls through its match and the command does nothing.
4239 
4240 ### Scrolling a value: up is more, wheel or natural finger
4241 
4242 Every slider and spinbox the designer shows — the params pane's, the
4243 viewport and playbar menus', the palette's — turns by cce-ui's
4244 `value_notches_y` (2026-09-30; see its CLAUDE.md, "A value control reads
4245 the wheel as up is more"): a wheel notch up is more, and with natural
4246 scrolling on, the fingers going up is more too. The palette's
4247 `scroll_slider` reads it as the toolkit controls do. The suite pins the
4248 setting per test thread with `cce_ui::input::force_natural_scroll`, since
4249 this test binary links cce-ui without `cfg(test)` and would read the
4250 machine's input.kdl: `a_trackpad_swipe_over_a_spinbox_row_steps_it` drives
4251 a spinbox with a finger both ways.
4252 
4253 ### The dialog (Alt+D, Ctrl+P, Tab)
4254 
4255 `src/dialog.rs` is the app's one modal overlay, and **every filterable list
4256 in the designer is an opening of it**. It is one roster slot, `DIALOG_IDX`,
4257 an app-owned `Dialog` that paints the plate, the query line and the row
4258 list — and the rows' controls, from the toolkit's own stamps (`Toggle`,
4259 `Slider`) and hosted `ColorSelector`s, so a slider in the dialog is the same
4260 slider as a slider in the params pane.
4261 
4262 `Mode` says what an opening is for, and it is the reason there is one widget
4263 rather than two:
4264 
4265 - `Mode::Commands` (**Alt+D**, **Ctrl+P**) — ONE list: every registry
4266   command, fuzzy-filtered in place, and every display setting
4267   `DesignSettings` persists, ranked among them. Until 2026-09-24 the
4268   settings were a second HALF behind a tab strip, a second `ParametersBg`
4269   slot (`DIALOG_PARAMS_IDX`) laid out inside the plate with section headers
4270   and no filter. A setting is something you ask for by name exactly as a
4271   command is, so it ranks in the same list; the strip, its two labels, the
4272   section rows and the second slot are gone, and with them the double
4273   paint, the `dispatch_uncovered` routing into a second slot and the
4274   `dialog_settings_shown` baseline the writeback diffed against.
4275 - `Mode::Rename` (the node menu's **Rename**, the `rename_node` command)
4276   — the query line is the NAME, opened holding the one the node has, and
4277   the one row (`RENAME_ROW_ID`) says what Enter will do: `Rename camera1
4278   to lens`, the name as it will be written (`sanitize_node_name`, so
4279   `My Ball` reads `my_ball` before it is committed), or why it will not
4280   be — its name already, another node's, none. `State::rename_check` is
4281   that rule and `State::rename_node` the one entry the dialog and MCP's
4282   `rename_node` share; a sibling's name is refused in both, since wires
4283   are by name. `State::rename_target` holds the node by id.
4284   `a_node_is_renamed_from_its_menu` is the test.
4285 - `Mode::Groups` (the `group_markers` command, **Group Markers** in the
4286   palette — the palette TRANSFORMS into this list, since 2026-09-29) —
4287   the scene's point groups, one row each with a switch and the member
4288   count in the chord column, filtered by name. A row's switch marks the
4289   group's members in the viewport: a sphere at Group Marker Size in the
4290   selected group's amber, on every member, staying on whatever is
4291   selected. Enter or a click flips it in place and the list stays up, as
4292   the palette's toggles do. `State::marked_groups` is the set, persisted
4293   in the viewport block (one comma-joined string — a KDL list of one
4294   reads back as a bare string, which a `Vec` refuses, and a settings file
4295   that fails to parse reads as the defaults), so it rides the project
4296   file too. `State::scene_groups` is every point group of the scene as
4297   last built with its members' positions, kept by `rebuild_scene_geometry`
4298   so the list and the markers (`rebuild_marked_group_markers`,
4299   `meshes.marked_points`) come from what is on screen and a switch
4300   evaluates nothing; the markers follow the geometry through a rebuild.
4301   A marked name the scene has no group for marks nothing and is kept, so
4302   a group that comes and goes with a frame does not lose its switch.
4303   `the_group_markers_dialog_marks_a_groups_points` is the test.
4304 - `Mode::AddNode` (**Tab**, in the network pane) — one list of node
4305   templates, and a pick that instantiates at the grid cursor. Tab is what
4306   opened it, so Tab closes it again. The query hint names the mode; there
4307   is no title band, so the two openings are the same plate.
4308 
4309 Both modes share the plate, the keys and `fuzzy_rank`, which is the whole
4310 point — the app used to put two filterable lists in front of the user that
4311 looked and behaved nothing alike.
4312 
4313 **A row's control is `Row::control`, an `Option<Control>`**, and a row that
4314 has one is worked IN PLACE — the dialog stays up, the control re-reads, the
4315 selection stays where it was:
4316 
4317 - `Toggle` — a toggle command's switch (`command_toggle_state` is the
4318   table, the same read the View menu's checkmarks are set from), painted as
4319   the toolkit's `Toggle` in a right-hand column reserved for every row as
4320   soon as any row has one, so the chord column keeps a straight edge. Enter
4321   or a click flips it. `dialog_toggle_rows_cover_every_toggle_command` fails
4322   when a `toggle_*` / `show_*_pane` command is added without an arm in the
4323   table, because the miss is silent — the row just ships plain.
4324 - `Slider` — a value over a range, to `dec` decimals; `dec` 0 snaps to
4325   whole numbers, which is the spinbox shape (Grid Thickness in thousandths,
4326   Origin Size in tenths — the units those params always used). One toolkit
4327   `Slider` stamp in a `RefCell` serves every slider row, set to each row's
4328   range and value as it is painted. The band **begins `SLIDER_W` in from the
4329   row's right end and runs out to the CHORD column's right edge**, so it
4330   ends where every other row's key binding ends and the switch column stays
4331   clear; the readout sits AHEAD of the band, and a press tests the band
4332   alone — over the whole control a click on the readout would jump the
4333   value to whichever end of the range it abuts. A press jumps to the
4334   pointer and arms the app's widget-drag protocol on `DIALOG_IDX`
4335   (`Dialog::draggable` / `drag_*`), so the value follows the pointer off the
4336   plate; the wheel over the control turns it (2% of the range a notch) where
4337   over the rest of the list it scrolls; Left/Right nudge it by the row's
4338   `step` while it is selected; Enter on it runs nothing. **A slider row
4339   lands through `State::land_draw_time_setting`** — the one landing the
4340   viewport menu's sliders use, by `DesignSettings` field key: the field,
4341   the one marker mesh it feeds, a redraw, then `refresh_dialog_controls`
4342   — NOT `apply_setting`, and state.kdl is written once on the drag's
4343   release (`dialog_mouse_input`), or at once for a wheel notch or arrow
4344   key. Until 2026-09-28 every motion of a drag ran the full apply: a graph
4345   evaluation, two more keyed on the version it bumped (group markers, the
4346   params pane's pickers), a path-tracer restart and a synchronous file
4347   write, per pointer event, for six values the graph never reads — which
4348   is what made the dialog's sliders drag behind the pointer while the
4349   menu's did not. The spin rows (Grid Thickness, Origin Size, Camera
4350   Pivot Size) land the same way, their whole number
4351   over the row's unit, each re-baking only the guide mesh that reads it;
4352   `a_dialog_slider_drag_lands_without_re_evaluating_the_graph` pins all
4353   of it. The **zoom row**
4354   (`ZOOM_ROW_ID`, only while the network pane is focused, since zoom is that
4355   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
4356   range the pitch limits), landing through `set_zoom_percent`, which zooms
4357   about the cursor cell and re-reads the row, since `zoom` clamps.
4358 - `Choice` — a fixed set (World Unit, GPU, Node Wire Style): the PARAMS
4359   PANE'S DROPDOWN (since 2026-10-01,
4360   the toolkit `Dropdown`), in the control band the sliders and colour
4361   wells use. Closed, a row draws `Dialog::dropdown_stamp` — one
4362   `Dropdown` handed each row's options and selection as it is painted,
4363   as the toggle and slider stamps are. A press on the row or Enter opens
4364   `Dialog::dropdown`, the LIVE one (`State::open_dialog_dropdown`): it
4365   takes the row's options, is laid out on the row's band
4366   (`sync_dialog_dropdown`), focused and sent Enter, and its plate GROWS
4367   out of the trigger into the list and shrinks back, the toolkit's own
4368   animation and frosted style — painted after the rows
4369   (`render_popover`), over them. Up/Down/Enter/Escape are the dropdown's
4370   own; Tab closes it; Left/Right on a closed row still step it in place;
4371   every pick lands through `land_dialog_choice`. Four things it took:
4372   - **Text is painted twice** (`paint_retagged`): a trigger's text must
4373     carry the DIALOG's bounds or the dialog's occluder clamps it away.
4374   - **The open plate is an occluder registered AFTER the dialog's** —
4375     after the slot registration in `collect_display_list`, and after its
4376     `clear_hierarchy`, which wipes the widget tree: registered before
4377     that, the id stayed in `active_popovers` but resolved to nothing, the
4378     engine's clamp skipped it in silence, and the list's labels were
4379     clamped while the rows under it showed through. The clamp lets an
4380     occluder's own labels through only past the occluders registered
4381     before it, so the order is the whole trick.
4382   - **The runner hands the press to the dropdown first.** Every left
4383     press goes to each registered popover whose hit test MISSES it
4384     before the app is asked (`close_popovers_missed_by_press`), and the
4385     dialog's claim covers the dropdown, so it always misses: the
4386     dropdown has taken the press — picked a row, or closed — before
4387     `handle_event` runs. `Dialog::dropdown_armed` remembers it was
4388     expanded; a press that finds it not expanded while armed is one it
4389     already took, and `dialog_dropdown_press` lands the pick and
4390     swallows the press, so the row under the list is not pressed too.
4391     The test's press does what the runner does, or it would not have
4392     caught this.
4393   - **Closing the dialog shuts it outright** (`open = false`), so no
4394     shrinking plate is left reporting a popover over the panes.
4395   `Dropdown::is_expanded` (cce-ui, the same day) is what tells a
4396   shrinking dropdown from one taking input. The closed trigger is in the dropdown's
4397   own font, not the dialog's: cce-ui's Dropdown names it on its text
4398   (see its CLAUDE.md, "A dropdown's text names its font"), where a stamp
4399   painted here took the dialog's and the list opened in another. For one day before this the
4400   choice was the context menu shown as a list under the row, and before
4401   that a click stepped the value between two chevrons.
4402   `a_choice_row_is_a_dropdown` is the test.
4403 - `Color` — a hex colour. Behind each colour row the
4404   dialog keeps one toolkit `ColorSelector` (`Dialog::colors`, by row id,
4405   kept across re-rankings so a query that drops the row does not kill its
4406   picker): a real widget, not a stamp, because it carries state — a hex
4407   edit in progress, a `cce-color-editor` process streaming values. It is
4408   painted over the band and handed presses on the band with the band as its
4409   rect (`color_event`); `Dialog::tick` polls it; a change comes out of
4410   `take_color_changes`. **While its hex well is being typed into it has the
4411   keyboard ahead of everything** — `dialog_key_input` forwards to
4412   `editing_color` first, so Escape and Enter end the edit rather than the
4413   dialog.
4414 
4415 **A setting row edits the live field** — see "There are no meta nodes"
4416 above, which is where these values used to live and why a direct write did
4417 not stick. `SETTINGS` is the table of rows and their owners, each an
4418 `Owner::Field` (a live field, with a `Ctl` saying what control draws it,
4419 since a bare Rust field carries no type or range the way a param did).
4420 There was a second kind, `Owner::ActiveCamera` — an active-camera param
4421 with the live field as its fallback — whose one row, Camera Pivot Size,
4422 went on 2026-09-30: no camera node has that param, so the row only ever
4423 wrote the field, which the viewport menu's slider sets. The toggles the retired
4424 subnets held are NOT rows of the table: each is a registry command with a
4425 switch on its own row, and a second row per toggle would have listed every
4426 switch twice. A row's id is its label under `SETTING_ROW_PREFIX`
4427 (`setting_of_row` resolves it back), its control is built by
4428 `setting_control` from the value `setting_value` reads (the params pane's
4429 encodings — a hex, a whole number in the spin's unit, an option's text),
4430 and every change lands through `apply_setting(label, value)`: write to the
4431 owner, then the one regenerate-and-persist pass (the viewport meshes bake
4432 their sizes and colours in) and `refresh_dialog_controls`, which re-reads
4433 every control in place — not `refresh_dialog_rows`, which re-ranks and
4434 would throw the selection to the top. `dialog_settings_rows_name_owners_that_exist`
4435 is the backstop, because the failure is silent — a `Field` key no dispatch
4436 arm names reads a default and writes nowhere, so the row draws, takes an
4437 edit and does nothing, which is why that test round-trips every one of them.
4438 **Group Marker Size** is the one row added with the collapse: the
4439 Selected-Group markers' radius in world units
4440 (`State::group_marker_size`, persisted in the render block; it was a
4441 multiple of the retired Point Size until 2026-09-29).
4442 
4443 **The open project's PATH heads the Commands list**, as a row rather than a
4444 command (`PATH_ROW_ID`): the label is the path, the chord column carries the
4445 file name — the palette's readout of what is being edited, in the column a
4446 command's chord would use — and picking it copies the path to the clipboard
4447 and closes, a copy being done the moment it happens. It ranks against the
4448 path text like any other row, so a query finds or drops it.
4449 
4450 Two details. The label truncates on the LEFT (`Row::truncate_head`, the
4451 paint's `fit_head`), because the tail of a path is what identifies it and a
4452 row cut down to `/home/me/pro...` would name every project in the directory
4453 equally badly. And there is NO row when no project is loaded: the bundled
4454 `default_project.json` leaves `loaded_project_path` None on purpose (the
4455 window title and Set As Default take the same position), and a row offering
4456 to copy a path into a versioned file in the source tree would be a trap.
4457 `project_path_readout` reads the name with `file_name()`, the same call the
4458 window title makes, so the two cannot disagree about what is open.
4459 
4460 The actual clipboard write is `#[cfg(not(test))]`. `wl-copy` has to OUTLIVE
4461 its caller to serve the selection, and it inherits the test binary's captured
4462 stdout — so a test that really copied left cargo waiting on a pipe held open
4463 by a clipboard daemon, which looks exactly like a hung suite.
4464 
4465 **Alt+D, not Super+D.** Every Super chord is the compositor's before any client
4466 sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
4467 launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
4468 app's own — the `move_*` family already lives there.
4469 
4470 **The dialog plate IS the menu plate** (since 2026-09-28). The render arm
4471 draws it with `cce_ui::widget::context_menu::paint_menu_plate`, the one
4472 function the context menus draw theirs with: `Material::menu` — the
4473 `style.surface.menu` block's `color` (a cce-ui key added the same day;
4474 absent, the root plate colour as menus always wore), `opacity` and
4475 `compression` — on `menu.corner_radius` with the relief-width roll. So
4476 the command palette, the Add Node list and every right-click menu are
4477 configured in ONE block and cannot be configured apart; the shared
4478 config.kdl's `menu color=(rgba)"#101018ff"` sets that block to the look the
4479 dialog had (there is no per-app `cce-designer/config.kdl` on the machine as
4480 of 2026-09-28 — only `.bak` copies — so the main file is where it is set).
4481 Until then the dialog
4482 was the parameter plate's fill under a compression of its own
4483 (`DIALOG_COMPRESSION` 0.8, `style.surface.dialog.compression`) — an
4484 in-app override the menus did not share, and the reason the two plates
4485 looked nothing alike. `the_dialog_plate_is_the_menu_plate` scans the
4486 source for that override coming back. The one difference left is
4487 mechanical: the menus are hosted in the runner's popup surface, where the
4488 compositor's blur cannot compress and the helper folds `compression` into
4489 opacity, while the dialog is in-window and the in-app frost pass
4490 compresses as configured.
4491 
4492 **The dialog is painted after the overlay passes, not in the widget walk.** A
4493 high `z_order` is not enough: `append_frame_text` and the
4494 point-number overlay all run AFTER the whole walk, so the graph's node labels drew
4495 straight over a dialog that had already covered them. `append_dialog` runs
4496 after the popovers, before the context menu, instead.
4497 
4498 **And even that is not enough, because text is not painted in display-list
4499 order.** The engine collects every `Prim::Text` and lays them all out at the end,
4500 so a plate over a label does not hide it at any depth. What hides it is the
4501 engine's popover-occlusion clamp, which reads `UiContext::active_popovers` — so
4502 `Dialog::popover` claims the dialog's whole rect, and the designer's
4503 registration loop picks it up. The clamp exempts text whose own bounds COINCIDE
4504 with the occluder, so every label inside the dialog carries the dialog's rect and
4505 truncates itself; a hosted colour selector is painted twice for this (once
4506 for its well and swatch, once into a scratch `PaintCtx` whose text alone is
4507 re-emitted retagged), since a `PaintCtx` can be handed text back but not
4508 geometry.
4509 
4510 **`Dialog::occluding` exists because that one claim serves two mechanisms that
4511 want opposite answers.** `UiContext::is_coordinate_covered` reads the same
4512 `popover_rect` — off every REGISTERED widget, not just the ones in
4513 `active_popovers` — to decide a press landed under something else. With the claim
4514 standing, every control inside the plate is covered by the plate it is drawn
4515 on and nothing can be clicked; the toggles looked laid out, painted, and
4516 completely inert. `State::dispatch_uncovered` lowers the flag for the length of a
4517 dispatch into the dialog and puts it back, invalidating the coverage memo on both
4518 edges (the engine queries it on every left press, so lowering the flag alone
4519 leaves a stale cached answer).
4520 
4521 **A control in the dialog lifts under the pointer** (`Dialog::hover_ctl`,
4522 since 2026-09-29): the row whose switch, slider (readout lane included)
4523 or colour well the pointer is over, found by `control_rect` on every
4524 move. The toggle and slider stamps serve every row, so each is told the
4525 hover as its row is painted (`Toggle::set_hovered`, `Slider::set_hovered`
4526 — the two setters cce-ui grew for it, since a stamp is never routed a
4527 `MouseEnter`); a colour row's selector is a widget of its own and is
4528 told by `MouseEnter` / `MouseLeave` through `color_event` as the pointer
4529 crosses its band. The hover clears with the row's when the pointer
4530 leaves the plate, since `broadcast_pointer` hands the dialog an
4531 off-screen position then. `a_palette_control_lifts_under_the_pointer`
4532 is the test.
4533 
4534 **A captured pointer hovers no pane.** `State::broadcast_pointer` hands
4535 every slot the pointer's position, or an off-screen one while
4536 `pointer_captured` says a gesture or the dialog owns it — a widget or app
4537 drag, an orbit, a pan, a grid expansion, a handle grab, a held menu
4538 slider. It runs from the cursor arm (ahead of the early returns those
4539 gestures take, which is where a pane hovered at the press used to stay lit
4540 for the whole drag), from the release once the captures are down, and
4541 from the dialog's open and close, since a modal that let the panes beside
4542 its plate keep hovering was a modal in name.
4543 `a_captured_pointer_hovers_no_pane_and_the_release_hands_it_back` is the
4544 test.
4545 
4546 Input is intercepted whole, at the top of `handle_event`'s keyboard and mouse
4547 branches: `dialog_key_input` is TOTAL rather than a layer, because the network
4548 pane's bare-letter family is ungated and typing "frame" into the filter would
4549 otherwise step the grid cursor four times and flip a node's geometry toggle on
4550 the way past. A press outside the plate dismisses and is swallowed, the way the
4551 node and plate menus behave.
4552 
4553 **Nothing in this crate shells out to `cce-cloud` any more**, and
4554 `nothing_shells_out_to_cce_cloud_any_more` scans the source to keep it that
4555 way. Retiring the two popups took a surprising amount of scaffolding with
4556 them: `CloudPopupTracker` (the single-active-popup toggle bookkeeping), the
4557 `CloudSpawned` / `CloudClosed` events that adopted a popup's pid, the
4558 `RunCommand(&'static str)` event that existed because the popup ran on its own
4559 thread and could not touch `State`, and the `libc` dependency, whose only use
4560 was `kill`ing a stray popup. `active_menu_cloud_pid` / `_idx` and the
4561 `menu_closed` MCP tool went too — they were already dead, left from a retired
4562 attempt at menubar dropdowns over `cce-cloud`, and nothing had set them to
4563 `Some` in a long time. `cce_ui::process::CloudPopup` outlived that by nine
4564 days as unused public API in a shared crate — the designer had been its only
4565 consumer — and went on 2026-09-28 along with the whole `process` module and
4566 cce-ui's `tokio` dependency, which existed for nothing else.
4567 
4568 ### Runtime paths point into the source tree
4569 
4570 Node templates (`nodes/*.json`) and `default_project.json` are located via
4571 `env!("CARGO_MANIFEST_DIR")` — the installed binary still reads from the source
4572 checkout. Templates are resolved recursively: a template's children reference other
4573 templates by `type`, merged with param overrides (`load_fs_tree` in `src/app.rs`).
4574 Missing referenced templates panic at load.
4575 
4576 Saved instances are self-contained copies, but the loader merges template
4577 evolution into them (`merge_template_defs` in `src/app.rs`, run on every
4578 project deserialization including thumbnails): missing params are inserted
4579 where the template puts them (after the last template param the instance
4580 already has — so the Sphere's Method lands above Radius in an old save, not
4581 below Color), existing ones keep their value but take the template's UI
4582 metadata, and a subnet template (the Embryo, since the four kernel subnets
4583 went native) refreshes its children's params and any child's `Code`
4584 outright — **the template owns the surface and implementation, the
4585 instance owns its values.** A script hand-edited inside a template
4586 instance reverts on load; custom scripts belong in bare wrangle nodes,
4587 which the merge never touches.
4588 Native nodes match their template by type, subnet instances by name
4589 ("sphere3" → "Sphere", case-insensitively) plus a full child name/type match; the merge never
4590 injects or deletes children and never rewrites files on disk.
4591 
4592 ### Node names are lowercase and carry no whitespace
4593 
4594 A node's name is a segment of its path — `/sphere1/opencl1` is how the
4595 breadcrumb, the MCP tools and every `Input` wire name it — so names are
4596 lowercase, as Houdini's are, and carry no spaces (since 2026-09-21).
4597 `sanitize_node_name` (src/app.rs) is the rule: the conventional space
4598 between a template name and its index goes ("Sphere 1" → "sphere1", which
4599 is also what minting now produces), any other whitespace becomes an
4600 underscore ("My Region" → "my_region"), the whole thing is lowercased, and
4601 empty comes back as `node`, because a path convention with exceptions is two
4602 conventions. The template merge matches an instance to its template
4603 case-insensitively ("sphere3" → "Sphere"). It runs at every entry point — minting, the
4604 `add_node` name override, `rename_node` — and as a LOAD-TIME MIGRATION on
4605 every load path, `Project::sanitize_node_names`, called before the template
4606 merge in all five places a project is deserialized (the two `load_from_file`
4607 branches, `State::new`, the thumbnail and the export CLI).
4608 
4609 The migration follows references, because wires are by name: within each
4610 level it renames the children, then rewrites any sibling parameter whose
4611 value was one of the old names (`Input`, `With`, `Rest`, `Target`, `Source`,
4612 `Collider` — any of them, since it matches values rather than a list), and
4613 maps the view state's active camera, the one reference outside the tree. A
4614 sanitized name that lands on a sibling's ("Sphere 1" beside a hand-named
4615 "sphere1") steps aside with a `_2` suffix rather than leaving two nodes one
4616 name and every wire to them ambiguous.
4617 
4618 ## Repo hygiene
4619 
4620 `scratch/` holds ad-hoc debug scripts/logs and `screenshot*.png` at the root are
4621 debugging artifacts — not source, don't extend them. Tests live in
4622 `src/main.rs`'s `#[cfg(test)]` module; add new ones there. The exception is
4623 `tests/`, which holds the two tests that SCAN the crate's own source —
4624 `doc_claims.rs` (CLAUDE.md's `(~Nk lines)` figures) and `user_paths.rs` (below)
4625 — and they are out there because a scanner under `src/` is the first thing it
4626 finds. Both are deliberately mirrored per crate rather than shared, since every
4627 crate here is its own git repository that must build standalone; they need
4628 nothing but `std`, so copying one into a sibling is the whole job. Commit
4629 messages follow `feat:` / `fix:` / `refactor:` style (see `git log`).
4630 
4631 **`tests/user_paths.rs` refuses source that builds a path the user owns**, the
4632 class of bug that had this suite rewriting `~/.config/cce/cce-designer/state.kdl`
4633 on every run (see "App-written settings" above). Two rules, one per shape that
4634 actually shipped: no line assembles a config path out of `.config` by hand —
4635 `cce_ui::config::cce_config_dir()` is the only way in, because that is what the
4636 `cfg(test)` redirect keys off — and every `temp_dir()` is scoped with
4637 `std::process::id()` within a line or two, since /tmp is one namespace shared
4638 with every other user and every concurrent run. Verified by reintroducing each
4639 bug: both are caught, naming the file and line. The `temp_dir()` rule is not
4640 limited to tests, because a fixed /tmp name is no better in shipped code.
4641 
4642 Two runtime guards sit alongside it in `src/main.rs`, since a scan cannot see
4643 behaviour: `the_suite_does_not_write_the_users_own_settings` and
4644 `the_recent_files_list_is_not_the_users` each snapshot the real file, exercise
4645 the write path, and assert it did not move — the second checking the load side
4646 too, because reading the user's recent list would make the suite's behaviour
4647 depend on the machine.