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.