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

shapeshifter.md (47.5K)

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