Today’s post is a bit different from my usual deep dives into mesh geometry. It’s a story about architecture – and about going back to the beginning.
Think back to how you learned to program. Almost certainly, you started with procedural code: a program was a sequence of steps, maybe split into functions. Input, transformation, output. Simple.
Then someone opened the door to a whole new world: object-oriented programming. Everything is an object! Objects have state and behavior! Model the real world! And we believed it. We spent years – years – learning how to carve reality into classes. Is a Circle an Ellipse? Should Document own View, or observe it? Composition or inheritance?
Then we discovered that plain OOP doesn’t really scale, and we fell into the world of patterns: MVC, MVVM, Observer, Dependency Injection, service locators, factories of factories. Each pattern solved a real problem – and each added a layer of indirection between “what the program does” and “what the code says.”
The result is familiar to anyone who has maintained a large application. You open the codebase and the actual behavior is nowhere. It’s scattered across a dependency graph that exists only at runtime, wired together by a DI container, held up by callbacks and event buses. To answer the question “what happens if this value changes?” you do archaeology: grep, breakpoints, tracing through callbacks.
To be fair, AI made this archaeology much cheaper. I can point a model at a legacy codebase, ask “who reacts to this event?”, and get a decent answer in seconds. But AI made understanding bad architecture cheaper – it didn’t make designing good architecture any less of a craft. That part is still on us.
The task: a 3D editor
Some time ago I needed to write a 3D mesh editor. Not a toy – a real application: load meshes, run geometric operations on them (clipping, stitching, simplification), undo/redo, a UI for parameters, and, critically, the ability to keep adding operations for years without the whole thing collapsing.
With a task like this, the first question is always architecture. Extensibility, maintainability, easy feature growth – everything we want so the project can evolve for years without a rewrite.
The classical move is obvious: decompose into objects. Mesh, Scene, Operation, Command, Tool, ToolManager, Selection, SelectionChangedEvent… I’ve built systems like that. I know exactly how it goes: six months in, the object graph is a living organism that nobody fully understands, and adding a feature means negotiating with it. So this time I didn’t do that. Instead, I went back to the very beginning – to the mental model we all started with.
The model: processes, programs, and tables of cells
Here is the entire architecture, in four sentences:
- A process is a set of programs.
- A program is a table of named variables with declared dataflow between them (I call it a sheet of cells).
- Some cells hold plain values; some are computed from other cells. The dependency graph between them is declared explicitly, as data.
- Computed cells come in two kinds: formulas, which produce a value from their inputs, and actions, which perform an effect – mutate geometry, run a transaction – when the user says so.
That’s it. Sounds almost primitive, right? A program is literally a table: names on the left, values on the right, plus computed cells wired to their inputs.
If this reminds you of a spreadsheet – good. The names Sheet and Cell are not an accident. Excel is the most successful reactive programming environment ever created. Hundreds of millions of people build working “applications” in it without ever drawing a class diagram. You look at a cell, you see its formula, you see its inputs – the dependency graph is the program. Even the formula/action split is Excel’s: formulas recalculate, macros run when you press the button.
The same idea lives in Grasshopper and similar node-based tools: a program is a dataflow graph, each node has inputs and outputs, and the wiring between nodes is the application. My editor is essentially that, expressed in C++ instead of boxes and wires.
And the pattern reaches well beyond software. Look at how complex manufacturing is organized – the PLM/PDM world, where things like rockets get built. Thousands of people, departments that know almost nothing about each other: one team develops fuel, another designs fasteners. Nobody holds the whole product in their head. What holds it together is decomposition into programs – in the wider sense: a method of reaching a defined goal within the production chain – each with declared inputs and outputs, connected by a digital thread, so that a change in one propagates to everything it affects. Program management and systems engineering exist precisely to maintain that graph. The industry that builds the most complex objects on Earth doesn’t organize them as object hierarchies; it organizes them as programs and the dataflow between them.
So in my world, every mesh operation is a program – effectively a plugin. Each program declares its inputs and outputs as cells. That’s the whole contract.
The code
Here is a real program from the editor – the clipping operation (see the post on mesh clipping with OpenMesh):
class Clip final : public Program {public: using Program::Program; void operator()(iUndoRedoManager& undoRedo) override { auto& inputMeshes = sheet().unchecked_try_emplace<CellVector<RefMut<iMesh>>>("Meshes"); // input geometries to transform auto& planeOrigin = sheet().unchecked_try_emplace<Cell>("Plane Origin", glm::dvec3{ 0,0,0 }); auto& planeRotation = sheet().unchecked_try_emplace<Cell>("Plane Rotation", glm::dvec3{ 0,0,0 }); auto& keepBothParts = sheet().unchecked_try_emplace<Cell>("Keep Both Parts", true); auto& epsilon = sheet().unchecked_try_emplace<Cell>("Epsilon", 1.0e-5); static constexpr auto clip = [](std::span<const RefMut<iMesh>> meshes, glm::dvec3 origin, glm::dvec3 rotation, bool keepBoth, double eps, iUndoRedoManager& undoRedo) { auto rotMat = glm::gtc::rotate(glm::radians(rotation[0]), glm::dvec3(1, 0, 0)) * glm::gtc::rotate(glm::radians(rotation[1]), glm::dvec3(0, 1, 0)) * glm::gtc::rotate(glm::radians(rotation[2]), glm::dvec3(0, 0, 1)); auto tb = build(undoRedo); for (auto mesh : meshes) tb.add<ClipOperation>(mesh, origin, rotMat[2], keepBoth, eps); tb.commit(); }; sheet().unchecked_try_emplace<Action<clip>>("Clip", inputMeshes, planeOrigin, planeRotation, keepBothParts, epsilon, undoRedo); }};
Let’s go through all program.
The signature. The program receives exactly one thing: the undo/redo manager it records into. Everything else the program needs, it declares itself, as cells.
The inputs. The program populates its sheet with cells:
Meshes– a vector of mutable references to the meshes we’re going to clip;Plane Origin– a point on the clipping plane;Plane Rotation– rotation angles that define the plane orientation;Keep Both Parts– do we keep both halves or discard one;Epsilon– the tolerance.
Each cell has a name, a type, and a default value. And, as a bonus, the UI panel for this operation is generated automatically from this very table. Cell of bool → checkbox. Cell of glm::dvec3 → three number fields. And when the default widgets aren’t enough, a mapping from cells to custom controls can be described in XML or JSON – the panel is still driven by the sheet, just with a nicer skin. Either way, I never write parameter dialogs by hand. Cells are also serializable – and so are formulas and actions – so the whole program state persists with the document: the same tables that drive the UI drive persistence.
The computation. clip is a plain lambda: it takes the values of the input cells, builds a rotation matrix, and applies the clipping operation to each mesh. Note tb: the lambda doesn’t modify meshes directly. It collects the operations into a transaction and commits it to the undo/redo manager. One commit – one entry in the undo stack. Every program mutates geometry this way, so undo/redo works across the whole editor without any program implementing it. (Undo/redo for half-edge structures is described here).
The wiring. The last line registers clip on the sheet under the name Clip and binds it to the input cells. From this moment on, the runtime knows the dependency graph: for any cell, it can tell which computations consume it. The type spells out what kind of node this is: clip returns void, so it’s an Action, not a Formula. It doesn’t fire while the user drags Plane Origin around – it runs when the user presses the Clip button, once, as a single undoable transaction. That’s the correct semantics for a destructive operation: parameters settle first, the effect happens on command. And whenever it runs, the slots fetch the current values of the cells – whatever the user has set by that moment. Notice also what the program never did: it never subscribed to anything, never emitted an event, never injected a ClipService. Declaring the inputs was the wiring.
Every question you’d ask in a code review is answered by looking at the class itself:
- What does it consume? The cells it creates.
- What does it produce? Here – mutated meshes, through a transaction.
- When does it run? On the user’s command – it’s an action.
I don’t think about objects and their relationships at all. I see what comes in, what goes out, and what depends on what.
Values flowing between programs
Clip produces its output by mutating meshes in place, which slightly hides the dataflow. So here is a second program that makes the flow explicit – it reads the same meshes and produces two plain output cells:
class Measure final : public Program {public: using Program::Program; void operator()(iUndoRedoManager& undoRedo) override { auto& inputMeshes = sheet().unchecked_try_emplace<CellVector<RefMut<iMesh>>>("Meshes"); auto& volume = sheet().unchecked_try_emplace<Cell>("Volume", 0.0); auto& area = sheet().unchecked_try_emplace<Cell>("Area", 0.0); static constexpr auto calculateVolume = [](std::span<const RefMut<iMesh>> meshes) { double v = 0; for (auto mesh : meshes) v += mesh->volume(); return v; }; static constexpr auto calculateArea = [](std::span<const RefMut<iMesh>> meshes) { double a = 0; for (auto mesh : meshes) a += mesh->area(); return a; }; sheet().unchecked_try_emplace<Formula<calculateVolume>>("Calculate Volume", volume, inputMeshes); sheet().unchecked_try_emplace<Formula<calculateArea>>("Calculate Area", area, inputMeshes); }};
Note the difference from Clip. Here the lambdas return values, so these are Formula nodes – and a formula’s output is its own cell. Volume and Area are declared first, as ordinary cells with defaults; Calculate Volume binds a target and its sources, and on every run reads the current mesh state and delivers the result into the target. The sheet reads like a table: inputs (Meshes), outputs (Volume, Area), and the computations connecting them. And since Volume and Area are just cells, anything downstream can depend on them: the UI reads them to display measurements; another program could read them to drive further logic.
Now put the two programs together and the chain becomes visible. The user sets Plane Origin and presses Clip → the action runs, one transaction, the meshes change → Volume and Area recompute → the labels on screen update. Today that recompute step is also explicit: a computation runs when it’s forced – from a button or programmatically – and whenever it runs, it reads the current values of its cells. Nothing recomputes on its own, which is why I call this declarative dataflow with manual execution rather than reactive programming. The building blocks for reactivity are in place, though. The dependency graph sits in the sheets as data, and every cell already fires an onValueChanged notification – the generated UI relies on it to keep widgets in sync. What connects the two is a scheduler, and that’s Part 2.
Why a scheduler? The simple way seems obvious: let every computation listen to its inputs’ onValueChanged and run as soon as one of them changes. But this falls apart fast. If two inputs change at once, the computation runs twice – and the first run sees old data. If the user drags a slider, everything recalculates on every mouse move. If one computation writes its result, the next one starts before the first has even finished. And in the end you get the same mess of callbacks we were trying to escape from. So a change should not run anything. It should only leave a mark: “this needs an update.” And at the end of the event, everything marked is updated – once, in the right order.
One more practical question: in which order does all this run? Two phases. When a program is added to a process, its operator() runs once – this is setup: it creates cells and registers computations, which is how the runtime learns the dependency graph. Execution is everything after. Today it’s simple: a computation runs when told to, and reads whatever its input cells hold at that moment. There’s nothing to order yet – nothing runs on its own. Real ordering comes with the scheduler. The graph it needs is already there, in the sheets.
“But you just moved the complexity somewhere else”
Yes! And that’s precisely the point. The complexity of a real application never disappears – it only changes representation. In the OOP version of this editor, the complexity lives in an implicit object graph: who holds a pointer to whom, who subscribed to what, in what order events fire. That graph exists only at runtime and only in the heads of the two people who wrote it.
In the sheet-based version, the same complexity lives in an explicit dependency graph. And explicit changes everything:
- It’s local. To understand
Clip, you readClip. All its dependencies are named in one place. There is no action at a distance. - It’s inspectable. The sheet is data. I can dump it, serialize it, diff it.
- It’s uniform. Every feature in the editor has exactly the same shape: a table of cells plus computations.
Where did OOP go?
It didn’t go anywhere – it went back to where it’s actually good.
Look at the code again: iMesh, iUndoRedoManager, the transaction builder. Interfaces, polymorphism, encapsulation – all still there. The half-edge mesh is still a carefully encapsulated data structure. OOP is excellent at building components with clean boundaries.
What changed is that objects stopped being the organizing principle of the application. The application is organized as processes → programs → cells. In retrospect, I think this is where many of us went wrong: OOP is a great tool for designing components – things with a boundary, an invariant, a clear lifetime – but a poor organizing principle for a whole system. Organize a system as objects, and its behavior lives in a runtime object graph that no single class describes.
Benefits
- Adding a feature = adding a class like
Clip. No “register it in these four places.” The mesh-stitching operation, smooth, clip – each one is a self-contained program. - UI for free. Parameter panels are generated from sheets. When I add a cell, the UI updates itself.
- Easy to read. When someone new looks at the code, it takes 5 min to read: “a program is a table of cells with declared dataflow between them; here’s
Clip;”
Is this a silver bullet? Of course not. Dataflow has its cons – cycles in the dependency graph, computations that are expensive enough to need scheduling, etc.
First, meshes are mutable. A cell like Meshes doesn’t hold a value the way a spreadsheet cell holds a number – it holds references to structures that programs modify in place. So “this cell changed” means much less than in Excel: changed how? which part? does a computation downstream actually care about that part? Copying meshes around to get value semantics is not an option at real sizes, so the dataflow model has to live with aliasing.
Second, multithreading. The graph looks perfect for it: independent computations – just run them in parallel, right? But then the problems start. Two computations may write to the same mesh. Transactions may commit in the wrong order. The renderer may read a mesh in the middle of an update. The graph says what depends on what – it doesn’t say what can run at the same time. That’s a separate problem, and I haven’t solved it yet.
Third. Cells are just strings. The compiler doesn’t see them, and neither does the IDE: no Go to definition, no Rename, and a typo in a cell name shows up only at runtime. The values are still type-checked – a bool cell won’t give you a dvec3 – but the connections between cells are not checked by anyone. That’s the price of the flexibility.
I’ll write about those issues separately, because some of them were interesting.
Ownership and lifetime
One more design decision before closing: who owns whom.
Here the answer is simple on purpose: everything is a tree. A process owns its programs, a program owns its sheet, a sheet owns its cells. Top-down, no cycles, destroyed in reverse order.
Dependencies follow the same rule. A computation can only use cells of its own process. If a process needs data from outside, it declares its own inputs and outputs – just like a program does with cells. Same pattern, one level up.
This removes a whole class of problems. A computation in one process can’t quietly depend on a cell deep inside another. All connections between processes live at the boundary, where you can see them. To delete a process, you check its inputs and outputs – not the insides of every other process.
What the tree doesn’t answer is lifetime, and I’m still thinking about it:
- A program can be deleted while computations in a neighbor program still use its cells. The process can unbind them on deletion – but this has to be a defined event, not an accident.
- A cell like Meshes holds references to geometry owned by the document – an owner outside the tree. If a mesh is deleted, the cell must learn about it before some computation runs on a dead reference.
- UI widgets also reference cells. A widget is just one more dependent, and it must follow the same rules.
My working rule so far: owning and depending are two different things. Ownership is the tree. A dependency never owns anything, and “my input is gone” must be a normal, expected event: the computation is unbound and marked invalid in its sheet – not left with a dangling pointer. How to do this cheaply – generation counters, weak handles, notifications – deserves its own post.
I don’t have all the answers – some things I’ve solved, some postponed, some I’ve only recently learned to put into words. Which makes them good material for the next posts: the internals of Cell, Sheet, Formula, and Action; the scheduler that turns this dataflow from manual into reactive; and how processes connect into larger systems.
But the main lesson of this post stands, and it’s a bit uncomfortable. After twenty years of collecting architectural sophistication, the design that finally made my application easy to grow is the one we all understood in our first year of programming: a program is inputs, outputs, and the flow between them. We just needed to stop being ashamed of it.
As always, feel free to leave comments and ask questions.
Leave a comment