graphic design tool
git clone https://git.lucas.co/cce-designer.git
shapeshifter.md (47.9K)
1 # Shapeshifter in cce-designer
2
3 Proposal v0 | September 2026
4
5 Bringing the Developer, Immutable Methods and GEM toolsets out of `hou-control`
6 and into this app — which is mostly not a porting job. It is one data-structure
7 decision, then about ten nodes that do the work of fifty.
8
9 The source material is `~/projects/hou-control`: its `developer.md` (the
10 Shapeshifter design document), `otls-audit.md` (every HDA, its parameters, and
11 which of them nothing reads) and `shortcomings.md`.
12
13 ## What each side already has
14
15 The plugin is three tool families on top of an interaction layer. The app is a
16 working procedural pipeline with no vocabulary yet. They meet in fewer places
17 than the node counts suggest.
18
19 **hou-control, today**
20
21 - ~50 `developer_*` HDAs — attribute solvers, surface development, the Solver
22 and its Vis tabs.
23 - ~90 `im_*` HDAs — the general modeling vocabulary across Create, Topology,
24 Move, Filter, Analysis, Layout.
25 - ~30 `gem_*` HDAs — mold, sprue, build area, supports, plus a COP family for
26 printed output.
27 - 8.8k lines of `hc` — keycam navigator, HC Panel, hotkey JSON, network editor,
28 settings schema.
29
30 **cce-designer, today**
31
32 - Graph evaluation with feedback — `simnet` already iterates a chain, caches per
33 frame, and restarts on edit.
34 - Two kernel backends — OpenCL, plus the CPU interpreter in `src/kernel_cpu.rs`
35 that is the semantic reference.
36 - The panes — network, parameters, spreadsheet, playbar, viewport; detachable,
37 pinnable, saved in the project.
38 - Declared world units — mm/cm/m/in, a scale readout, View 1:1.
39
40 **Not there yet**
41
42 - Points, primitives and detail — there is only a vertex list.
43 - Any notion of a neighbour, an edge, or a stable point id.
44 - Attributes on the GPU — kernels see positions and colors, nothing else.
45 - Remeshing, collision, volumes, mesh export.
46 - A command palette, a hotkey file, a viewer-state framework beyond the one
47 curve tool.
48
49 ## The one thing that blocks everything
50
51 Every operator in the Developer set is a statement about a point and its
52 neighbours. Diffuse averages toward neighbours. Migrate moves value along edges
53 and the sender loses what the target gains. Concentrate sharpens against
54 neighbours. Lead turns each vector toward the neighbouring vector that disagrees
55 most. Analysis writes the spread of edge lengths.
56
57 `src/geometry.rs`:
58
59 ```rust
60 pub struct Geometry {
61 pub vertices: Vec<GVertex>, // a triangle soup
62 }
63
64 pub struct GVertex {
65 pub pos: [f32; 3],
66 pub col: [f32; 3],
67 pub attributes: HashMap<String, GAttribute>, // per triangle corner
68 }
69 ```
70
71 Three corners of a triangle are three unrelated entries. A point shared by six
72 faces appears six times with six independent copies of every attribute. There is
73 no way to ask what a point's neighbours are, no edge to measure, and no identity
74 that survives a frame. Groups are faked as `group:name` keys in the attribute
75 map; attributes are `Float` through `Float4` only, so there are no integers for
76 counters or ids.
77
78 Nothing in Phases 1 through 3 can be written against this, and a workaround —
79 welding on demand inside each operator — would put an O(n log n) rebuild inside
80 every node of a chain that runs once per simulation step. So the proposal starts
81 there, and the first phase is the expensive one.
82
83 ## Phases
84
85 Ordered by dependency, not by appeal. Phase 5 is deliberately detached — it
86 touches none of the geometry work and can be picked up in any gap.
87
88 ### Phase 0 — A real geometry model
89
90 *Largest. Blocks Phases 1, 2, 3, 4 and 6 — all of them.*
91
92 > **Mostly landed.** `src/detail.rs` holds the container — points, vertices,
93 > primitives, detail; columnar attributes with integers and real groups; stable
94 > `PointId`s; lazily built CSR topology. The generators build it, every operator
95 > works on it, and the pipeline's currency IS a `Detail`: the spreadsheet lists
96 > points, the overlays read the point and edge lists, and `weld_points` is gone.
97 >
98 > What remains: kernel GENERATORS still emit a corner list that welds on the
99 > way back, so `detail_to_soup` / `soup_to_detail` and `geometry::Geometry`
100 > survive to serve them. Deformers no longer go near a soup (see Phase 1).
101
102 Replace the vertex list with **points, vertices, primitives and detail**, each
103 carrying its own columnar attribute arrays — one `Vec<f32>` per named attribute
104 rather than a `HashMap` per corner. Columnar is not a nicety here: it is the
105 shape a GPU buffer already wants, so Phase 1 becomes a pointer instead of a
106 marshalling pass.
107
108 Three things ride along. A **stable `id`** on points, allocated once and
109 preserved through every operator that does not create points — the thing every
110 solver needs and the thing a triangle soup can never have. A **lazily built
111 topology cache** (point→point, point→prim, edge list) invalidated on topology
112 change, so a chain of ten attribute nodes builds it once. And **real groups and
113 integer attributes**, retiring the `group:` key convention.
114
115 Triangulation becomes a render concern: `to_vertex3d_vec` is already the
116 boundary, and the raster and RT paths keep consuming triangles.
117
118 Touches: `geometry.rs`, `kernel_cpu.rs`, `render.rs`, `curve_tool.rs`, the
119 Spreadsheet, the meta overlays, `project.rs`.
120
121 ### Phase 1 — Attribute algebra, and attributes on the GPU
122
123 *Large. Needs Phase 0. Blocks Phases 2 and 3.*
124
125 > **Started.** The ABI is widened for DEFORMERS: the work item is a point, not
126 > a triangle corner, and a kernel reaches named float attributes through
127 > buffers of its own — `attrf("mass", i)` reads, `setattrf("mass", i, v)`
128 > writes, naming one creates it. Landed in both backends together and held to
129 > each other by a cross-backend test. A deformer no longer flattens or welds,
130 > so topology, groups and point identities pass through untouched.
131 >
132 > The `neighbour` node is in, as a native Rust evaluator: Diffuse,
133 > Concentrate, Migrate and Bleed over a Neighbourhood of connectivity rings,
134 > radius or global, componentwise on any attribute type. Per decision 1 the
135 > neighbourhood walk stays out of the kernel language.
136 >
137 > The attribute vocabulary is in too. `attribute` grew Remap, Clip, Normalize,
138 > Composite and Promote alongside Create/Modify/Delete; `analysis` writes min,
139 > max, sum, average, spread and count to DETAIL attributes — five ordinary
140 > attributes where Houdini writes an `<attr>_info` dictionary, which is
141 > decision 3 paying off; `time` runs 0 to 1 across a frame range. The
142 > spreadsheet shows detail attributes as `d:` columns.
143 >
144 > Align and Lead landed too, so `neighbour` is the full 7 → 1. Charge is in
145 > the node as well, but it is NOT a port: `developer_charge` is an empty shell
146 > in hou-control (two parameters, neither read), so what is implemented is the
147 > reading its parameter names suggest — accumulate, and discharge to the
148 > neighbours on crossing a threshold. Confirm or redirect it.
149 >
150 > Outstanding: the generator ABI, still a corner list out.
151
152 Widen the kernel ABI from `(in_pos, in_col, out_pos, out_col, params)` to
153 **named attribute buffers bound by the node**, plus the topology arrays as
154 read-only buffers so a kernel can walk neighbours. This is the change that has
155 to land in both backends at once — the OpenCL launcher and the CPU interpreter
156 that `cpu_matches_opencl_on_every_shipped_kernel` holds it to.
157
158 On top of it, the attribute vocabulary: **Initialize, Remap, Clip, Composite,
159 Promote, Analysis** and the `time` family — and one `neighbour` node whose Mode
160 covers Diffuse, Concentrate, Migrate, Bleed, Align and Lead, over a
161 Neighbourhood of connectivity, radius or global. That single node is most of the
162 `developer_*` set.
163
164 Touches: `geometry.rs`, `kernel_cpu.rs`, `nodes/*.json`.
165
166 ### Phase 2 — The solver contract
167
168 *Medium. Needs Phases 0 and 1.*
169
170 > **Started.** Live and derivative data is a declared property of the
171 > ATTRIBUTE (`AttribKind`), set where it is created rather than listed on the
172 > solver. The step boundary zeroes every derivative attribute going in, and
173 > coming out restores any live attribute the chain DROPPED, matching points by
174 > identity — so a node that rebuilds geometry mid-chain no longer takes the
175 > simulation's memory with it. Restoration bridges a rebuild, not a delete: an
176 > unchanged point set means a missing attribute was removed on purpose.
177 > Analysis and Time write derivative; the spreadsheet marks them with `~`.
178 >
179 > `visualize` is in: Ramp maps a scalar through one of four built-in ramps
180 > into `Cd`, Vector stages a vector attribute as viewport markers. Several
181 > attributes at once come from CHAINING Visualize nodes, each blending into
182 > the `Cd` it was handed, which is how the plugin's Solver Vis tabs work.
183 > Range Auto re-measures every run, because a simulation's interesting range
184 > moves every frame.
185 >
186 > Substeps run the chain N times per frame, so step size stops being tied to
187 > the frame rate. The solver writes `dt` (= 1/substeps) as a derivative detail
188 > attribute; a chain that scales its rate by it — Promote onto points, then
189 > Composite — covers the same ground however finely the frame is cut, which is
190 > what makes substeps a stability control rather than a speed control.
191 >
192 > A simnet can declare its own Start Frame (empty follows the timeline), and
193 > opt into a disk cache — the solved state parked under `$XDG_CACHE_HOME`, in a
194 > compact binary form `Detail` reads and writes itself, keyed by the same hash
195 > that invalidates the in-memory cache.
196 >
197 > Phase 2 is done.
198
199 `simnet` is already the Developer Solver — feedback stack, per-node cache keyed
200 on the subtree, restart on edit, one step per played frame. What it lacks is a
201 contract for what survives a step.
202
203 Make **live and derivative data a first-class distinction** rather than a
204 convention: an attribute is declared live (carried across the step boundary by
205 id) or derivative (zeroed at the start of every step). Derivative attributes
206 then cost nothing to get wrong, and a remesh in the middle of a chain has a
207 defined answer for every attribute it did not create — which is exactly what
208 `Surface Remesh` feeding `Develop` needs.
209
210 Then: substeps, an explicit seed frame, a disk cache so a long solve survives a
211 restart, and **Visualize** — attribute-to-color through ramps, several
212 composited at once, and vectors drawn as markers. The per-node `meta` overlays
213 already do the projection work this needs.
214
215 Touches: `geometry.rs`, `app.rs`, `render.rs`, `playbar.rs`.
216
217 ### Phase 3 — Surface development
218
219 *Large. Needs Phases 0, 1 and 2.*
220
221 > **Started.** `develop` displaces along the point normal by an attribute, and
222 > `remesh` is in as its own module (`src/remesh.rs`): split, collapse, flip and
223 > tangential relax, the Botsch–Kobbelt passes. It carries the simulation's data
224 > across — a split interpolates, a collapse keeps the survivor's identity and
225 > values, and a group only grows where both parents were members.
226 >
227 > Fixed on the way: the native generators wound BACKWARDS. Every normal on a
228 > native sphere pointed into it, and every face of a native box wound against
229 > the `Norm` it shipped. The template meshes were always right, which is why
230 > the winding test and the overlay test both passed; a path tracer shades both
231 > sides, so nothing looked wrong. Develop is the first operator whose answer
232 > depends on it, and it grew the surface inward.
233 >
234 > Collision is in. `detangle` is a point repulsion over Iterations passes with
235 > Thickness in edge lengths and a Rings exclusion — the audit's own
236 > description of the Shapeshifter algorithm. `suture` resolves against a second
237 > input, counts SUSTAINED contact, and fuses points past Fusion Threshold
238 > within Distance Threshold. Both, and the remesher's projection pass, run on
239 > `src/spatial.rs` — a uniform grid built once for all three.
240 >
241 > **`adapt` and `open` are not ported and should not be guessed at.**
242 > `developer_surface_adapt` has eight read parameters and no description
243 > anywhere; `developer_surface_open` has none at all. Unlike Charge, whose
244 > parameter names carried a reading, these carry nothing. Say what they do and
245 > they are a short job each.
246 >
247 > `subdivide` is in: four triangles where there was one, attributes
248 > interpolated onto the midpoints, and the shape left exactly where it was —
249 > it refines, it does not smooth, which is what separates it from Remesh.
250 >
251 > Phase 3 is done but for the two operators nobody can describe.
252
253 `Develop` is easy — displace along the normal by a development attribute.
254 **Remesh is the hard one**, and it is load-bearing: without topology that keeps
255 primitives proportional to surface area, every growth sim degenerates within a
256 few dozen frames. Incremental remeshing (split long edges, collapse short ones,
257 flip toward valence 6, tangential relaxation, project back to the input surface)
258 is well-trodden ground and should be its own module with its own tests, not a
259 node body.
260
261 Around it: `Subdivide`, `Adapt`, `Open`, and collision — `Detangle` as point
262 repulsion between non-neighbours to a thickness, and `Suture` resolving against
263 the previous frame and fusing what keeps colliding. Both want the spatial index
264 Phase 0's topology cache should already own.
265
266 Touches: a new `remesh.rs`, `geometry.rs`, `nodes/*.json`.
267
268 ### Phase 4 — The modeling set
269
270 *Wide, shallow. Needs Phase 0. Runs parallel with Phases 2 and 3.*
271
272 > **Started.** The measure-and-filter five: `normal` publishes the surface
273 > normal as an attribute anything can read, `bounds` and `distance` measure
274 > (the latter writing a direction too, out of the same lookup, which is what
275 > Migrate flows along), `connectivity` numbers pieces largest-first, and `cull`
276 > deletes — the first node in the app that removes geometry.
277 >
278 > The IM family carries no prose anywhere, but unlike `adapt` and `open` these
279 > names are unambiguous: a node called Normal computes normals. Where the audit
280 > recovered parameter names from the HDAs they are honoured (`piece_attr`,
281 > `dir_attr`).
282 >
283 > Copy and Soft Transform are in too, and `group` grew Attribute and Expand —
284 > selection by what a point IS rather than where it is, which is what makes
285 > the measuring nodes composable, plus grow/shrink across the surface.
286 >
287 > `points` and `scatter` can now emit BARE POINTS. Both drew marker spheres at
288 > every location, which is right for looking at and wrong for working with:
289 > Copy placed one instance per marker vertex rather than one per location,
290 > because the markers were the only points there were.
291 >
292 > `transfer` carries attributes from one geometry onto another by nearest
293 > point — how a field outlives the geometry it was defined on, which a chain
294 > that REBUILDS needs and a remesh cannot provide. `valence` publishes the
295 > number the remesher steers toward. `deform` collapses `im_twist`, `im_bend`
296 > and `im_curl` into one node: they are the same shape, a transform whose
297 > strength varies along an axis.
298 >
299 > The Create set grew a native `grid` (welded points, no kernel, so it can
300 > feed a remesh or a diffusion directly) and a `polygon` covering `im_square`,
301 > `im_triangle`, `im_star` and the circle nobody got round to — one shape with
302 > one parameter varying.
303 >
304 > Remaining: Select (which is `group` with more criteria, not a new node), and
305 > the long tail — which the audit prunes hard (version forks, the
306 > dead-on-arrival nodes, the Houdini-SOP wrappers).
307
308 The `im_*` family, which is the part that looks biggest and is actually the
309 easiest — ninety nodes, most of them a screenful once points and prims exist.
310 Sequence it by what the Developer chain consumes rather than by category:
311 **Group, Select, Cull, Transform, Soft Transform, Relax, Copy, Scatter, Bounds,
312 Distance, Neighbours, Connectivity, Normal** first, the Create primitives next,
313 then Analysis and Visualize.
314
315 `otls-audit.md` is the parameter spec — it lists every node's parm count and
316 flags the 74 unread parameters on `gem_build_area` and the dead ones elsewhere.
317 Port the surface you meant, not the one that accumulated.
318
319 Touches: `geometry.rs`, `nodes/*.json`.
320
321 ### Phase 5 — The interaction layer
322
323 *Medium. Needs nothing — start any time.*
324
325 > **Started.** Parameters can declare `show_when`, a condition over their
326 > siblings' values, and the pane shows only the rows that apply: `attribute`
327 > drops from seventeen rows to seven, `group` from fifteen to eight,
328 > `neighbour` from thirteen to seven. This was the cost of the 50 → 10
329 > collapse coming due — a pane of twelve irrelevant rows is worse than the
330 > twelve nodes it replaced — and it is the first Phase 5 item because it was
331 > the binding constraint on using what Phases 1 to 4 built.
332 >
333 > **The command registry and the palette landed.** `src/command.rs` is one list
334 > of everything the app can do — id, label, context, how to run it, default
335 > chord — and `ShortcutManager` now binds command IDS rather than `Action`s,
336 > which is what lets a chord reach a menu-dispatched command like Open at all.
337 > `State::run_command(id)` is the single entry point and is exposed over MCP,
338 > so every command is scriptable.
339 >
340 > The palette is the node palette's `cce-cloud --dmenu` popup rather than a new
341 > widget: two pickers in one app that behave differently is worse than either.
342 > Ranking reproduces the plugin's fuzzyfinder exactly — shortest span, earliest
343 > start, alphabetical — so muscle memory survives; the focused pane's commands
344 > partition to the front without anything being hidden. Rows carry their chord
345 > in a column, so the palette teaches the keyboard instead of replacing it.
346 >
347 > Two findings the registry paid for immediately. The toolkit's runner already
348 > claims ctrl+z, ctrl+shift+z, ctrl+tab and ctrl+shift+tab before the app sees
349 > them, so undo and redo ship with no chord HERE on purpose — binding ctrl+z
350 > would have taken undo away from focused text boxes while looking like a fix.
351 > And `Shortcut`'s derived `PartialEq` compared character keys case-sensitively
352 > while `matches()` compared them case-insensitively: `Ctrl+S` and `Ctrl+s` were
353 > one keypress at the keyboard and two values in memory, so the new conflict
354 > detector silently failed to report the very collision it exists to catch.
355 >
356 > The hotkey file this phase asked for turns out to be already built and better
357 > than proposed: `input.kdl` is workspace-wide with per-app domains, so a chord
358 > is `cce-designer.<id>` there and the registry supplies the default. What was
359 > missing was not a file but a set of NAMES to put in it, and conflict
360 > reporting; both are in.
361 >
362 > **The viewer-state framework landed.** `src/viewer_state.rs` owns everything
363 > the curve tool had that was not about curves: projection, hit-testing, the
364 > drag model, per-gesture undo, binding by node id, write-back — plus the two
365 > things this phase asked for that it did not have, snapping and a HUD.
366 >
367 > What differs per tool is the `HandleSource` trait, and two implementations
368 > ship because an abstraction with one implementation has not been shown to be
369 > one. The curve is an open-ended list of world positions stored as world
370 > positions. The soft transform is a FIXED pair whose second handle is
371 > `Centre + Translation` — a derived position, converted both ways by the
372 > source, so the framework's drag maths never learns that one of its two world
373 > points is not a place. That conversion is the whole reason the trait exists.
374 >
375 > The one design question it forced: `write` is handed a full set of handles
376 > with no word about which moved, because it has to mean the same thing when
377 > the set came from an undo snapshot as when it came from a drag. So the soft
378 > transform's handles read as a vector with a base and a tip, and dragging
379 > either end changes the offset between them. The alternative — keep the
380 > translation fixed when the centre moves — would be right for the drag and
381 > would quietly discard half of every restored snapshot.
382 >
383 > `source_for` is the one map from node type to tool, so the context menu entry,
384 > the `edit_handles` command and anything later cannot disagree about what is
385 > editable: a new source appears in the menu without the menu being touched.
386 > The entry is now "Edit Handles", not "Edit Points" — only a curve's are
387 > points. Snapping is a command (`toggle_snap`), which is the previous round's
388 > registry paying for itself.
389 >
390 > **Keyboard graph navigation landed**, as the plugin's own scheme rather than
391 > an invention: hjkl bare to move the grid cursor (the arrows are the playbar
392 > transport in every pane), alt to move the node under it, ctrl to pan the view,
393 > `f` to frame the cursor and `shift+f` to frame everything. Fourteen registry
394 > commands in the network context, so all of it is rebindable through
395 > `input.kdl` and listed in the palette.
396 >
397 > Half of it already existed, hardcoded inline and unrebindable — and the bare
398 > family was the one that was never gated on the network pane, so plain hjkl
399 > drifted the cursor invisibly while you looked at the viewport. The ctrl pan
400 > family did not exist at all. Frame Cursor did not either: `f` framed
401 > everything, and the first version of the new command only scrolled the cursor
402 > into view, which does nothing in the case you actually press it in — it
403 > centres now.
404 >
405 > `shift+hjkl` is deliberately absent. The Graph widget carries a single
406 > `selected_node`, and four rows that quietly did what bare hjkl already does
407 > would be worse than the gap. It wants multi-selection first.
408 >
409 > The conflict check earned its place: `ctrl+h` was taken by `edit_handles` from
410 > the previous round, and the test named the winner and the shadowed command
411 > rather than leaving a key that silently stopped working.
412 >
413 > **Auto-layout landed, and Phase 5's list is done.** `src/layout.rs` arranges a
414 > level from its wiring: row is how far downstream a node is, column is chosen
415 > to sit under what it reads from. Because the network is already a grid of
416 > integer cells, this is a layered assignment rather than the usual
417 > force-directed sprawl — and a chain comes out as one vertical line, which is
418 > what a chain already looks like in every project in the repo.
419 >
420 > Edges come from the same rule the WIRES do: a node's `Input` naming another.
421 > A layout computed from relationships you cannot see would move nodes for
422 > reasons that are not on screen. The cost is that a second operand — a
423 > Boolean's `With` — does not pull on the layout, because it does not draw a
424 > wire either; those should become edges here in the same change that makes
425 > them wires.
426 >
427 > Depth iterates to a fixed point rather than recursing, because a name-wired
428 > graph can be cyclic: `A` reads `B` reads `A` is something a user can type, and
429 > it must terminate rather than overflow. Utility trees are pinned, and their
430 > cells count as occupied.
431 >
432 > The proposal's Phase 5 list is now complete: conditional parameter rows, the
433 > command registry and palette, the hotkey file (which turned out to already
434 > exist, better than proposed), the viewer-state framework, keyboard graph
435 > navigation, and auto-layout. The keycam navigator is named there as a viewer
436 > state ON that framework rather than as a list item, and is not written.
437
438 Independent of all the geometry work, and the place where the app gets to be
439 better rather than equal. A **command palette** on the HC Panel's model — fuzzy
440 search over every action, contextual to the focused pane — which in Houdini
441 exists partly because there is no API to open the native tab menu. Here it is
442 just a widget.
443
444 A **hotkey file** (kdl, alongside `state.kdl`) with conflict resolution,
445 extending `shortcut.rs`. **Keyboard graph navigation** and auto-layout in the
446 network pane. And a **viewer-state framework** generalized out of
447 `curve_tool.rs`, which CLAUDE.md already names as the pattern: handles,
448 snapping, a HUD, per-gesture undo. The keycam navigator is a viewer state on
449 that framework.
450
451 One thing gets deleted rather than ported. `hcviewregions.py` publishes pane
452 rectangles to `ccectl` so the compositor can synthesize a view drag from a
453 two-finger swipe, because a trackpad gesture never survives Xwayland. A native
454 Wayland client receives the gesture directly.
455
456 Touches: `shortcut.rs`, `app.rs`, `slots.rs`, `cce-ui`.
457
458 ### Phase 6 — GEM and manufacturing
459
460 *Largest. Needs Phases 0 and 3.*
461
462 > **Mesh export landed early**, out of order, because everything Phases 0 to 4
463 > build could until now only be looked at inside the app. `src/export.rs`
464 > writes STL and OBJ, there is an `export` node and a `--export` CLI mode, and
465 > a solved growth simulation can be written to a printable file.
466 >
467 > **The volume representation landed.** `src/volume.rs` is a dense signed
468 > distance field; the `volume` node offsets and shells, the `boolean` node
469 > unions, intersects and subtracts. Extraction is surface nets.
470 >
471 > Signing the field cost three wrong answers before a right one. Ray parity
472 > double-counts at shared edges and inverted 79 of 15625 samples. A flood fill
473 > from the grid boundary fixes that, but a 0.75-voxel band let it walk through
474 > a thin wall and a slab came back hollow — the band has to be a full voxel,
475 > because two samples one voxel apart cannot both be further than a voxel from
476 > a surface between them. And the band's own test, which asks the nearest face
477 > which side a sample is on, trusts the winding; a mesh wound inside out came
478 > back with its band signs alternating against the flood's, so the winding is
479 > now measured by the divergence theorem and the test flips to match.
480 >
481 > The limit worth knowing: one vertex per cell means a feature thinner than a
482 > voxel pinches. A subtraction's knife-edge rim leaves a handful of edges
483 > carrying four faces — watertight, but not manifold. `Detail` now distinguishes
484 > the two (`is_closed` / `is_manifold`), because voxelizing needs only the
485 > first and remeshing needs the second.
486 >
487 > Two of these were found by RENDERING rather than testing, which is now the
488 > third time this phase: a node whose arithmetic is right and whose wiring is
489 > never exercised looks exactly like a working node until you ask the viewport
490 > to draw it. Both new nodes now have resolver-level tests, not just unit tests
491 > on the field.
492 >
493 > **The 2D page context landed, and Phase 4 is done.** `src/page.rs` composes a
494 > printed sheet — inches, a DPI, straight-alpha RGBA — and four nodes build one:
495 > `page`, `page_grid`, `page_border`, `page_text`. It previews in the viewport's
496 > pane and exports a PNG whose pHYs chunk carries its physical size, so a
497 > printer lays the sheet out at the size it was composed at.
498 >
499 > It is a genuinely separate context, as proposed: page chains resolve through
500 > `resolve_page` and contribute nothing to the geometry the viewport draws.
501 > `export` is the only node in both, and what reaches it decides the format.
502 > `gem_graph` — the source family's everything-at-once node, 29 parameters — is
503 > deliberately not ported: it is the other four chained, which is the whole
504 > premise of the fifty-to-ten collapse.
505 >
506 > The blank-pane lesson: a new pane needs a `paint_widget` arm (the
507 > fall-through serves LEGACY widgets, so a modern-paint one draws nothing), a
508 > place in the `draw_order` sort (the viewport is full-bleed and panes float
509 > over it), and a line in the hand-listed roster test. Only the third fails
510 > loudly. Two shadow runs went into finding the first, and one of those was
511 > spent chasing a second designer process my kill had silently failed to stop —
512 > both were writing to one log, so I was reading one process's state against
513 > another's.
514 >
515 > Still outstanding: nothing in Phase 4.
516 >
517 > **Phase 6's first GEM operator landed.** `src/mold.rs` ports
518 > `gem_mold_shell`: remesh to a division size, measure curvature per point, map
519 > it through a ramp into a thickness range, and displace a copy of the surface
520 > inward by that much. Its four parameters are the plugin's, and the template's
521 > defaults are the numbers from the production notes for the cast that worked.
522 >
523 > The measure is deliberately dimensionless — the mean of
524 > `dot(normalize(neighbour - p), n)` — so it does not move when the model is
525 > scaled or re-tessellated. Thickness is chosen from it, and a measure that
526 > shifted with the remesh division size would give a shell whose thickness
527 > changed every time you re-tessellated. The map into the range is affine over
528 > a fixed -1..1 rather than normalized over the model, so adding a sharp corner
529 > somewhere cannot thin the whole shell.
530 >
531 > A rendering check turned up an unrelated hole: the `box` node has a template
532 > and is listed as a geometry node type, but no resolver was ever written for
533 > it, so it silently produces nothing and any chain reading from it resolves to
534 > nothing. Raised separately rather than fixed here.
535 >
536 > The volume representation, mesh export and the 2D page context — the three
537 > things this phase named as prerequisites — all landed earlier. What remains
538 > is the rest of the GEM set: sprue, supports, build area, partition, orient.
539
540 Furthest out because it needs infrastructure nothing else does: a **volume
541 representation** (SDF or sparse grid) for shelling, offsetting and boolean work,
542 without which mold, sprue and support tooling has nothing to stand on. Then
543 **mesh export** — STL and OBJ, which the app cannot do at all today.
544
545 The COP family (`gem_page`, `gem_border`, `gem_grid`, `gem_text_box`,
546 `gem_graph`) is a second context entirely — 2D, printed output — and is best
547 treated as a separate surface rather than smuggled into the geometry graph.
548
549 The groundwork that already landed is the right groundwork: a declared world
550 unit, a scale readout and View 1:1 are precisely what a manufacturing tool needs
551 and what the Developer set does not care about.
552
553 Touches: a new `volume.rs`, a new `export.rs`, a 2D page context.
554
555 ### Phase 7 — The scripting layer, and compute through the renderer
556
557 *Medium for the first three steps, large for the fourth. Needs Phase 0
558 (landed). Independent of Phases 5 and 6. Supersedes the outstanding half of
559 Phase 1 — the generator ABI — which is dropped rather than finished.*
560
561 **What the audit found (2026-09-24).** The app has one scripting surface, the
562 `opencl` node, and it is the wrong tool held the wrong way round:
563
564 - The language is a subset of OpenCL C, reached through a text preprocessor
565 that rewrites `chf`/`chi`/`chb`/`chv` and `attrf`/`setattrf` into positional
566 buffer arguments. Two ABIs: a GENERATOR takes and returns a triangle-corner
567 soup — position and colour only, 200k vertices at most — and is welded back
568 by position, which discards topology, groups, integer attributes and the
569 stable point ids Phase 0 exists to provide. A DEFORMER runs per point and
570 binds float attributes, and can reach nothing else: no neighbours, no
571 primitives, no groups, no non-float attribute.
572 - **Every shipped kernel is serial.** Sphere, Box, Plane and Extrude all run
573 under `if (id == 0)` — one work item doing loops. The GPU is a slow C
574 interpreter with a JIT compile and a synchronous buffer round trip on every
575 evaluation, and the parallel deformer ABI has no shipped template at all.
576 - Every ABI change lands twice: the OpenCL launcher (~930 lines of
577 `geometry.rs`) and the hand-written C-subset tree-walker in `kernel_cpu.rs`
578 (1,805 lines), held to each other by a cross-backend test. Decision 1 below
579 already leaned "stop growing the kernel language".
580 - The ICD is a liability. Rusticl closes file descriptors it does not own, so
581 the suite is reliable only under `CCE_KERNEL_CPU=1`, and nothing else in the
582 workspace loads OpenCL for anything.
583 - There is no expression language. A parameter is a literal or a whole-value
584 `ch("Name")`; the Attribute node's Value takes numeric literals only. The
585 question "half of what a neighbour has" cannot be asked from a parameter.
586 - The direction is already set: the 41 native evaluators of Phases 3 and 4
587 are Rust, and Grid was made native precisely because Plane's kernel costs a
588 compile and loses its welds. The plugin itself barely used wrangles (three
589 mentions in `otls-audit.md`; the HDAs are SOP networks), so VEX
590 compatibility constrains nothing.
591
592 The conclusion is not "a better GPU language". It is that the app has been
593 maintaining an interpreter for a language it should not be scripting in, and
594 that the parallelism a GPU offers belongs somewhere other than the user's
595 generator code. Four steps, in dependency order; the first three do not have
596 to be undone to reach the fourth.
597
598 > **Started (2026-09-24).** The `wrangle` node is in: `src/wrangle.rs` on
599 > Rhai 1.26, `nodes/wrangle.json`, Class Points / Primitives / Detail over a
600 > Group, the `@name` sugar with typed attribute creation, `ch` / `chs` / `chv`
601 > / `chi` resolved through `TreeScope` before the run, topology and nearest,
602 > deferred `addpoint` / `addprim` / `removepoint`, both budgets. Nine tests
603 > in `main.rs`. The params pane's code row is an editor since the same
604 > day: gutter, selection, clipboard, indenting, undo, apply on ctrl+enter
605 > rather than per keystroke, and the failing line flagged from the
606 > evaluation error. Not yet: `@N` write-back feeding the normal overlay,
607 > and a vertex class.
608
609 **Step 1 — a `wrangle` node on an embedded engine.** Houdini's attribwrangle,
610 the thing users actually reach for, on a scripting engine someone else
611 maintains. Rhai is the pick: pure Rust with no C toolchain, which every client
612 in this workspace already requires; sandboxed with an operation limit, which
613 is the step budget `kernel_cpu` reimplements by hand; scripts compile to an AST
614 once and cache by source, exactly as `OPENCL_CACHE` keys kernels; `Vec3`
615 registers from glam. Lua via mlua is faster but brings a C dependency and a
616 garbage collector; Koto and Rune are less settled.
617
618 The node: Input, Class (Points / Primitives / Detail), Group, Code. The script
619 runs once per element of the class, over the Group if one is named. The
620 binding is where the effort goes, and it is the Detail's own surface:
621
622 - `@P`, `@N`, `@Cd`, `@id`, `@ptnum` and `@name` for any attribute of any
623 type — float, int, vector — rewritten by the same sugar pass the kernel
624 preprocessor does for `attrf`, so `@mass += 2.0` reads as it does in VEX.
625 Naming an attribute creates it, as the deformer ABI already does.
626 - `ch("Name")` reads the node's own parameter and climbs with `../`, through
627 `resolve_param_refs` — the one resolver, so a wrangle inside a composed
628 subnet reaches the outer control like every other child.
629 - `neighbours(pt)`, `prims(pt)`, `points(prim)` off the Detail's derived
630 topology — what decision 1 kept out of the kernel language because the
631 interpreter could not carry it. `nearest(pos, r)` off the spatial index.
632 - `detail("name")` for detail attributes, `ingroup`/`setgroup` for groups,
633 `@Frame` and `@Time` from the `EvalSim`.
634 - `addpoint`, `addprim`, `removepoint` — deferred and applied after the run,
635 so a script iterating points sees a stable element count.
636
637 CPU only, deliberately. An interpreter is an order of magnitude or more below
638 native Rust; that is fine for a wrangle over tens of thousands of elements per
639 edit and wrong for a solver at a million per frame, which is step 4's job. A
640 script that fails reports through the node-error slot, as a kernel does, and
641 leaves the input passing through.
642
643 **Step 2 — parameter expressions, on `expr.rs`, not on Rhai.** The
644 parameter half is its own small language, `src/expr.rs`: a parameter whose
645 `expr` flag is set holds an expression rather than a value — `ch(path)` with
646 Houdini's relative paths, arithmetic, comparisons, `$F` / `$FF`, a fixed
647 function set, `if(cond, a, b)` in place of a ternary because `:` separates a
648 float3's components — and is evaluated every time the node is, through the
649 one `Scope` that `geometry.rs` implements over the tree. Settled
650 2026-09-24: keep it, and keep Rhai OUT of parameters. Two engines rather than
651 one, deliberately, because the two jobs want opposite things. A parameter
652 expression is read by every node in the graph on every evaluation, so it
653 wants a language small enough to be parsed and checked in a line and modelled
654 as a FLAG rather than sniffed from the text — a kernel's Code contains
655 `chf(`, a node name is an identifier and `0.5` is an expression too, so a
656 prefix convention would be wrong somewhere. A wrangle wants the opposite:
657 loops, functions, a standard library, an operation budget, and a runtime
658 the app does not maintain. Sharing one engine would drag Rhai's parse cost
659 and surface into every parameter read, or starve the wrangle of the language
660 it needs. The seam between them is the channel: a wrangle's `ch("Name")` in
661 step 1 resolves through `expr.rs`'s scope, so a referenced parameter that is
662 itself an expression evaluates before the wrangle sees it, and neither
663 language has to know the other exists.
664
665 > **Landed (2026-09-24).** `src/shapes.rs` — Sphere (all three methods,
666 > Cube as quads), Box (with a Center, without its dead Input), Plane, and
667 > Extrude as a WHOLE (walls on boundary edges only, where the kernel walled
668 > every interior edge). Saved kernel subnets migrate on load through
669 > `nativize_kernel_subnets`; the bundled project files were converted in
670 > place. Then the retirement: the `opencl` node, `kernel_cpu.rs`, the
671 > launcher and preprocessor, `opencl3`, `CCE_KERNEL_CPU` and the ICD
672 > hazard are gone. An `opencl` node in an old save passes its input through
673 > and reports itself. The suite runs with no GPU, no OpenCL and no
674 > environment variable.
675
676 **Step 3 — port the four kernel templates native, then retire OpenCL.**
677 Sphere, Box, Plane and Extrude are the only kernels that ship. A native
678 `sphere_detail` and `grid_detail` already exist (Plane IS the Grid); Box is
679 trivial; Extrude wants topology anyway, since the kernel version fans
680 everything to triangles where a native one keeps a quad a quad. Each becomes a
681 plain native node type and the subnet-template shape goes: a subnet exists to
682 be dived into, and there is nothing inside these to read once the kernel is
683 gone. Saved instances migrate on load the way `recompose_native_embryo`
684 already does in the other direction — id, name, position, flag and values
685 carry over, the kernel child is dropped.
686
687 With those four native the `opencl` node is the last consumer, and it is
688 retired with the runtime. An `opencl` node in an older save loads as a
689 pass-through that reports "OpenCL nodes are retired; rewrite as a wrangle"
690 through the error slot — visible, not silently dropped. What goes:
691
692 | Retired | Size |
693 |---|---|
694 | `kernel_cpu.rs` | 1,805 lines |
695 | launcher + preprocessor in `geometry.rs` | ~930 lines |
696 | the four template kernels | ~21k chars of C |
697 | `opencl3`, `CCE_KERNEL_CPU`, the ICD hazard and its documentation | — |
698
699 Nothing else in the workspace loads OpenCL, so the ICD bug leaves with it.
700
701 > **Started (2026-09-24): the compute-job API is in cce-ui.**
702 > `cce_ui::vk::{ComputeDevice, Kernel, Binding}` — `run` / `run_over`
703 > upload a list of bindings (read-write storage, read-only storage,
704 > uniform), dispatch a WGSL entry point on a headless device, wait, and
705 > read the read-write ones back; host-visible mapped buffers, pipelines
706 > cached by source, every failure an `Err` with naga's diagnostic. Four
707 > tests run on the machine's Vulkan (Intel Iris Xe here) and skip where
708 > there is none. In this crate: `src/gpu.rs` keeps a device per
709 > evaluation thread under `CCE_COMPUTE` (auto / cpu / gpu), and
710 > `src/springs.rs` is the first operator — Relax's Springs mode as one
711 > Jacobi solve on both backends, held together by `springs_gpu_matches_cpu`.
712 > The API grew `run_passes` for it — a whole iterative solve in one
713 > submission, ping-ponging on the device — because a pass submitted on its
714 > own lost to the CPU at every size; with it the GPU wins from the tens of
715 > thousands of points up (135k: 14 ms against 25 ms) and the auto
716 > threshold sits at 32k. Collision followed (`src/collide.rs`): the
717 > node's brute-force queries x triangles test as one dispatch, zero
718 > disagreements against the CPU, 9x faster at 3.6M pairs and 22x at 242M.
719 > Diffuse and Repel stay on the CPU on purpose — a single gather per
720 > evaluation cannot amortise a submission, and Repel's per-pass spatial
721 > grid does not chain — so step 4's shape is settled: the GPU takes the
722 > operators whose work is large per submission, and the measurements
723 > in CLAUDE.md say which those are.
724
725 **Step 4 — GPU compute, through the renderer.** Scripts do not run on the
726 GPU: no embedded language compiles to GPU code, and none should. Parallel work
727 needs a GPU language, and the right one here is WGSL, because the toolkit
728 already speaks it. cce-ui's Vulkan path compiles WGSL to SPIR-V at runtime
729 through naga, builds compute pipelines, binds storage buffers and dispatches
730 workgroups — that is the path tracer — and runs headless, since `--thumbnail`
731 already drives an offscreen device with no window. What is missing is a
732 generic COMPUTE-JOB API on cce-ui: upload N storage buffers, dispatch a
733 kernel, read the buffers back. Today the compute pipeline is internal to the
734 RT pass and reads back only an image. That is a shared-crate change and
735 falls under the concurrent-sessions rules.
736
737 Where the parallelism goes is the point of the step. Not into user-written
738 generators — every shipped one was serial, and emitting a mesh is not a
739 parallel problem. The work that is parallel is per-point math over large
740 counts inside a simulation step: `relax`, `neighbour`'s Diffuse and
741 Concentrate, `collision`, `soft_transform`, the mold's curvature. Those are
742 native nodes, written once, in WGSL, over the columnar attribute arrays Phase
743 0 laid out for exactly this ("the layout a GPU buffer already wants"), with
744 the user never touching GPU code. A user-authored GPU wrangle is one more
745 node with a WGSL Code parameter on the same API, and the `@name` rewrite from
746 step 1 carries over; it is optional and comes last.
747
748 Two tiers, then: the Rhai wrangle and parameter expressions for prototyping,
749 one-off attribute logic and anything under a hundred thousand elements per
750 edit; WGSL compute for the fixed set of operators that runs every frame of a
751 solve.
752
753 The one cost that does not go away: a GPU operator needs a CPU twin, or the
754 suite and a machine without Vulkan cannot run it. But the twin is the plain
755 Rust evaluator the node already has — the WGSL version is an accelerator over
756 it, held to it by the same cross-backend test the kernels use today — not a
757 hand-rolled interpreter for a second language. And Mesa's lavapipe runs real
758 Vulkan compute on the CPU with no code change, which is a headless story
759 OpenCL never had.
760
761 Touches: a new `wrangle.rs` and the Rhai dependency (step 1); `expr.rs`
762 only at the channel seam (step 2);
763 `geometry.rs`, `kernel_cpu.rs`, `nodes/{sphere,box,plane,extrude,opencl}.json`
764 and `Cargo.toml` (step 3, all deletions); `cce-ui/src/vk` for the compute-job
765 API and a `compute/` directory of WGSL operators here (step 4).
766
767 ## Fifty operators, ten nodes
768
769 The HDA count is an artifact of Houdini's economics — a variant is cheaper as a
770 new asset than as a new parameter, so the families split and then had to be
771 merged back. Starting fresh, the merge is the starting point. The Scalar and
772 Vector families already collapsed into one in September 2026.
773
774 | Node in cce-designer | Absorbs | From |
775 |---|---|---|
776 | `attribute` | Attribute Initialize, Constant, Clip, Remap, Combine, Composite, Promote, Select, Normalize, Weight | 10 → 1 |
777 | | *(landed)* | |
778 | `neighbour` | Diffuse, Concentrate, Migrate, Bleed, Align, Lead, Charge — one Mode, one Neighbourhood | 7 → 1 |
779 | | *(landed; Charge is a proposed reading, not a port)* | |
780 | `gradient` | Gradient, Rotate, Direction | 3 → 1 |
781 | `analysis` | Analysis, Measure, Metamax, Time Analysis, Region Center | 5 → 1 |
782 | `time` | Time, Time Ramp, Time Switch | 3 → 1 |
783 | `develop` | Develop, Cull, Expire, Vitality, ID, Release | 6 → 1 |
784 | `remesh` | Surface Remesh, Surface Subdivide, Surface Adapt, Surface Open | 4 → 1 |
785 | `collide` | Surface Detangle, Surface Suture | 2 → 1 |
786 | `visualize` | Visualize, and the Solver's Vis tabs | 2 → 1 |
787 | `simnet` (exists) | Developer Solver, Submute Begin, Submute End | 3 → 0 |
788 | **Developer set** | **Nine new nodes, one already written** | **45 → 9** |
789
790 ## Decisions to settle first
791
792 Four of these change what Phase 0 and Phase 1 look like, so they are worth
793 settling before the geometry model is written rather than after.
794
795 **1. Keep two kernel backends?** OpenCL plus a CPU interpreter means every ABI
796 change lands twice, and Phase 1 widens the ABI substantially. The interpreter
797 earns its keep as the semantic reference and keeps the suite green headless —
798 but it is a C-subset tree-walker with no vector types, and neighbour traversal
799 will strain it.
800 *Leaning:* keep both, but stop growing the kernel language — express
801 neighbourhood operators as native Rust evaluators and reserve kernels for
802 per-point math.
803
804 > **Settled (2026-09-24), by Phase 7: keep neither.** The audit found every
805 > shipped kernel serial and the interpreter carrying a language the app should
806 > not be scripting in. Per-point math moves to a Rhai wrangle on the CPU, the
807 > four kernel templates go native, and OpenCL is retired with both backends.
808 > GPU parallelism returns later as WGSL compute through the renderer, on the
809 > native solver operators rather than on user code.
810
811 **2. Native nodes or editable templates?** Sphere, Plane and Extrude are subnet
812 templates whose kernel code the loader owns — a hand-edit inside an instance
813 reverts on load. Native nodes are Rust and not user-editable at all. The
814 Developer set could go either way, and which one decides whether a new operator
815 can be prototyped without a rebuild.
816 *Leaning:* native for anything touching topology; templates for the per-point
817 ops, so the experimentation surface stays open where it is cheap.
818
819 > **Revised by Phase 7.** Native for every operator; the experimentation
820 > surface is the wrangle node, not an editable kernel. Templates survive as
821 > COMPOSITION — the Embryo, a subnet of ordinary nodes — which is the shape a
822 > user can learn from, where an editable kernel was only a shape they could
823 > break.
824
825 **3. How far does the attribute type system go?** Today: `Float` through
826 `Float4`. The Developer set needs integers (counters, ids, Vitality's ages) and
827 something dictionary-shaped — `Analysis` writes `<attr>_info` holding a range.
828 Adding integers is small; adding a dict type reaches into the spreadsheet, the
829 kernel ABI and serialization.
830 *Leaning:* integers and real groups in Phase 0. Replace the dictionary with
831 named detail attributes (`attr_min`, `attr_max`) unless there is a use for
832 nesting.
833
834 **4. Does existing work need to come across?** An HDA is VEX plus a SOP subnet;
835 neither has an equivalent here, so an importer would be a compiler for two
836 languages the app does not speak. If there are `.hip` scenes that must keep
837 running, that changes the priority order considerably.
838 *Leaning:* no importer. Rebuild the handful of setups worth keeping once the
839 vocabulary exists.
840
841 ## What not to port
842
843 - **Houdini wrappers.** `developer_measure` is the Measure SOP with a promotion;
844 `im_skeletonize`, `im_shortest_path` and the VDB nodes are thin skins over
845 serious Houdini implementations. Each is a research project on its own — take
846 them only where a chain actually needs one.
847 - **Dead on arrival.** `im_pose` and `im_sample` both failed to cook in
848 `otls-audit.md`, and `developer_charge`, `im_bend`, `im_manipulator` and
849 `im_scaffold` carry no read parameters at all. Do not carry forward what never
850 worked. (`developer_charge` is the one exception taken so far: its name and
851 its two parameter names were enough to design a mode around, and that mode is
852 labelled a proposal in the code.)
853 - **Version forks.** Eleven operators ship as two or three live versions
854 (`im_attractor` at 0.9, 1.0 and 1.1; `im_select` at 1.0 and 2.0). Port the
855 newest, once.
856 - **The unbuilt list.** Energize, Edge Analysis, Analyze Change and Region were
857 ideas without nodes in the first draft and still are. They belong in the
858 design, not the port.
859
860 ## Summary
861
862 Phase 0 is most of the risk and none of the fun. Phases 1 and 2 are where the
863 app starts doing something Houdini does not. Phase 6 is far enough out that it
864 should not influence any decision made now.
865
866 For a smaller first cut: Phase 0 plus the `neighbour` node alone is enough to
867 run a diffusion on a sphere and see it — which is the point at which the rest of
868 this becomes worth arguing about.
869
870 Phase 7 is the one phase that removes more than it adds. Its first three steps
871 replace a hand-maintained C interpreter and a GPU runtime nothing else uses
872 with an embedded engine for the wrangle beside the small expression language
873 parameters already have; the fourth puts GPU parallelism where it pays, under
874 the solver operators, through the renderer the app already has.