ASYNC
A 3D application in two halves: a C++ engine that owns the scene, and an interface that draws it. This is the reference for the two surfaces you can build on — Python for scripting, C++ for plug-ins.
The two repositories
| ASYNC-Core | The engine. The document, the command stack, the meshes and their operators, the file format, presence. C++20, and it knows nothing about Qt, GL or Python — a headless test, a replay and a peer in a session all evaluate the same scene with no interpreter in the process. |
|---|---|
| ASYNC | This repository: the viewport (document → Hydra →
Storm → the screen, with no Python on the draw path), the interface in
PySide6, and async_core_py — the bindings that make the core
scriptable. |
The one rule worth reading first
Only a command changes the document — decision D4, and everything else follows from it. A command is the single thing that travels to the other people in a session, goes on the undo stack, and tells the panels something happened. Anything that changed the scene another way would exist on one machine only, undo to nothing, and be invisible to every panel: that is not a style preference, it is the shape of every desync this project has had.
So cmds.polyCube() is not a function that makes a cube. It is
a function that runs the command that makes a cube — which is why it
undoes, replicates and redraws without you asking for any of it.
Two surfaces, and why they are different languages
Python — cmds | Scripting. Ninety verbs over the command layer: make things, place them, pick them, run operators, listen for changes. This is the surface for automating work, for tests, and for the Script Editor. |
|---|---|
C++ — Registrar | Extending. A plug-in registers
modifier stages, operators, node kinds and menu entries. It is C++ and not
Python on purpose (D66): a stage's run sits on the evaluation
path where passes go out across threads, and a Python callable would take
the GIL on every one of them — and the core must be able to evaluate a
document with no interpreter at all. |
Running a script
Two places, the same module.
In the application
The Script Editor is a dock at the bottom of the window — the console icon,
or Window › Panels. cmds is already imported.
Ctrl+Return runs the selection, or the whole tab if nothing is
selected. (Every key is in Settings › Hotkeys and can be moved.)
Headless
The module is async_core_py, built into build/.
Nothing here needs a window, a GL context or a running application — which is
what makes it usable from a test, a build step or a machine with no
screen.
PYTHONPATH=build python3
>>> from async_core_py import cmds
>>> cmds.polyCube(1.0, name="crate")
|crate
Build it first with ./run.sh, which configures CMake and starts
the application, or run CMake yourself against build/.
Finding your way around
cmds.help() lists what is there and
cmds.help("polyBevel") explains one verb. Everything on the next
page is that same text, read out of the module.
cmds
The scripting surface, grouped by what it is for. Read out of the compiled module — what is written here is what the build you have answers.
Answers
A refusal is a value, not an exception.
Every verb answers with an Answer: truthy when it worked,
carrying what it made and, when it did not, a sentence you can show somebody.
Nothing raises for an ordinary "no" — a layer that is locked, a node that is
gone, a file that is not a scene are all answers, because all three are things
a person did rather than things the program got wrong.
a = cmds.polyCube(1.0, name="crate")
if a:
print(a.node) # '|crate'
else:
print(a.message) # why not, in words
str(a) # '|crate', or 'rejected: "props" is locked'
A worked example
Make a crate, move it, bevel two of its edges and put a subdivision on top — destructive nothing, undoable everything:
from async_core_py import cmds
crate = cmds.polyCube(1.0, name="crate").node
cmds.xform(crate, translation=(0, 1, 0))
shape = cmds.ls(crate, shapes=True)[0]
cmds.polyBevel(shape=shape, edges=[0, 1], width=0.1, segments=2)
cmds.polyAddModifier(crate, "subdivision")
cmds.polyEvaluate(shape)
# {'vertices': 8, 'edges': 12, 'faces': 6, 'valid': True, 'version': 0}
|crate, and its shape
|crate|crateShape. cmds.ls(shapes=True) lists both.Being told, rather than asking
Panels do not poll. cmds.subscribe calls you back once per
command with the list of what changed, and there is a second channel for what a
drag is doing before it becomes a step:
token = cmds.subscribe(lambda changes: print([c["kind"] for c in changes]))
cmds.polyCube(1.0) # ['added', 'added', ...]
cmds.unsubscribe(token)
Undo, and chunks
There is one stack, it belongs to the core, and everything is already on it. What you get to decide is what counts as one step.
Why you never have to write an undo
A command knows how to take itself back. That is what a command is
here: a thing that can run, be undone, be redone, say what it touched, and
write itself down to be sent to somebody else. Because every verb in
cmds runs one, undo, redo, replication and the history panel come
for free — and an operator that a plug-in registers gets all of it without
writing a line of undo code, because one core command runs any operator
(D67).
The consequence worth internalising: if it did not go through a command, it did not happen. There is no second way to change a scene that quietly skips the stack.
One step is one command — unless you say otherwise
cmds.undo() / cmds.redo() |
Move one step. In a session this takes back the newest step whoever ran it, and says so when it was somebody else's (D29, D45). |
|---|---|
cmds.undoName() / cmds.redoName() |
What that step is called — what a menu entry says instead of a bare "Undo". |
cmds.undoTarget() / cmds.redoTarget() |
The same, plus whose it is: the sentence a person is owed before pressing the key rather than after. |
cmds.undoDepth() |
How many steps are applied. The number that says whether a command pushed a step at all. |
cmds.undoHistory() |
Every step: its name, whose it is, and whether it is currently applied. |
Chunks: many commands, one Ctrl+Z
An action is often one thing to the person doing it and several commands underneath — build a wall, rig a limb, import and place. Say so, and the whole sequence becomes a single step (D52).
The block form cannot be left open, so prefer it:
with cmds.undoChunk("a wall"):
for i in range(3):
cmds.xform(cmds.polyCube(1.0).node, translation=(i * 2, 0, 0))
cmds.undoName() # 'a wall'
cmds.undoDepth() # 1 in a fresh scene - six commands, one step
cmds.undo() # the whole wall goes at once
When a block cannot express it — a chunk that begins in one callback and ends in another, a tool that opens on mouse-down and closes on mouse-up, a script that builds something across several functions — open and close it by hand:
cmds.openChunk("a wall")
for i in range(3):
cmds.xform(cmds.polyCube(1.0).node, translation=(i * 2, 0, 0))
cmds.closeChunk()
cmds.undo() # still one step
# and when a gesture is abandoned half way:
cmds.openChunk("thrown away")
cmds.polyCube(1.0)
cmds.cancelChunk() # everything it collected is undone, no step is left
cmds.openChunk(name="edit") | Start collecting. |
|---|---|
cmds.closeChunk() | End the step. Refuses, in words, when nothing is open. |
cmds.cancelChunk() | Abandon it: undo what it collected and leave no step. |
cmds.inChunk() | Whether one is being collected — for a script that has to know whether somebody above it already opened one. |
What is deliberately not on the stack
Three kinds of thing run through the same machinery and leave no step, because a person pressing Ctrl+Z did not mean any of them:
| Looking around | Orbiting a camera is not an edit. It crosses to other people as presence so they see where you are, and it is not something to walk back through (D61, D64). |
|---|---|
| Picking things | A selection is one person's. It never becomes an edit anybody else's document has to agree about (D26, D27). |
| A drag, while it is happening | Sixty frames of a slider are not sixty steps. A drag runs as a draft on its own channel and lands as one command when you let go (D16). |
In a session
The timeline is shared: Ctrl+Z takes back the newest step in the room, whoever made it, and the application says whose it was rather than silently undoing a stranger's work (D45). A step that cannot be taken back because somebody has been at those nodes since is refused with a sentence rather than applied to a scene that has moved under it (D30).
viewport
The control surface of a viewport widget: what it draws, how it draws it, and which camera it looks through. The application's own toolbar drives this directly rather than through a wrapper.
Writing a plug-in
A shared library that registers things when it is loaded. The shape is Maya's, because Maya's is right and people already know it.
The two entry points
#include "async/core/plugin.h"
using namespace async::core;
ASYNC_PLUGIN_EXPORT void asyncInitializePlugin(Registrar& into) {
StageType twist;
twist.name = "twist"; // in files, in cmds, on the wire
twist.label = "Twist"; // what a person reads
twist.kind = ModifierKind::Deform; // it moves points, it adds none
twist.params = {
{"angle", "Angle", 45.0f, -360.0f, 360.0f},
{"axis", "Axis", 1, 0.0f, 2.0f, {"x", "y", "z"}},
};
twist.run = [](Mesh& mesh, const Params& p) {
const float angle = p.number("angle");
// ... move mesh.points ...
};
into.stage(twist);
}
ASYNC_PLUGIN_EXPORT void asyncUninitializePlugin() {}
ASYNC_PLUGIN_EXPORT is extern "C" plus one
platform word. It has to be a macro because Windows exports nothing from a DLL
unless it is asked: the two functions compiled and linked and the manager
still said "it exports no asyncInitializePlugin", which is true and says
nothing about where to look.
plugin field the registrar fills in, so unloading is complete
rather than nearly complete. A plug-in that forgot one would leave a function
pointer into a library about to be unmapped, and that crash arrives later and
somewhere else. asyncUninitializePlugin is for whatever
else you own — a thread, a socket, a subscription.What a registrar gives you
into.stage(StageType) | A kind of modifier stage. False if the name is taken (D65). |
|---|---|
into.op(OperatorType) | An edit. It gets undo, replication and a place in the history for free (D67). |
into.action(ActionType) | Something in a menu. |
into.node(NodeType) | A kind of object with state of its own in the document (D69). |
into.document() | The scene. Read it freely; change it only through a command. |
into.commands() | The stack — the only way anything may change the document (D4, D24). |
into.has_scene() | False in a headless tool that loaded you for your operators alone. Registering still works; asking for the document does not. |
into.plugin() | Your own name, which is how you
find your own folder: Plugins::get().find(into.plugin())->path.
This is the only moment anything knows where you live. |
Where they are found
The application scans a search path at start-up and lists what it finds in
the Plug-in Manager — installed, loaded, and set to load on start. From a
script: cmds.scanPlugins(path),
cmds.loadPlugin("asyncTwist"), cmds.plugins(),
cmds.pluginAutoLoad("asyncTwist", True).
What you can register
Four types, and the fields are the whole contract. Each one is a plain struct you fill in and hand over.
StageType — an entry in the modifier stack
name | The identifier: "bevel". In
files, in cmds, on the wire. A name and not a number, because
numbers cannot be handed out to people nobody knows about — a name
collision is loud and fixable, a number collision is a scene that opens
wrong (D65). |
|---|---|
label | What a person reads in the Add menu: "Simple Deform", not "deform". |
kind | Deform moves points and
changes no topology; Construct may change anything. Known
before it runs, because that is what lets the stack decide what to
cache and what can go to the GPU (D56). |
params | In the order they are drawn and written. |
run | void(Mesh&, const Params&)
— flat arrays in, flat arrays out (D3). |
plugin | Filled in for you. It is what unloading works from. |
run must be a pure function of the mesh
and the parameters. No clock, no unseeded random, no iteration over an
unordered container that reaches the result. A stack is evaluated on every peer
and has to give the same answer everywhere (D8), and the cache above it assumes
the same thing for a different reason (D55).OperatorType — an edit
name, label | As above. |
|---|---|
domain | What must be picked for it to mean
anything: SelectMode::Face, Edge,
Vertex. The core checks it before running, so every operator
refuses in the same words and a menu can grey itself out without asking
the operator. |
needs_selection | True for almost everything; a "select all boundary edges" would say false. |
params | What the operator panel draws, and what a person can adjust after the fact. |
run | bool(EMesh&, const Selection&,
const Params&). False means it did nothing and
the caller puts the mesh back — a half-applied operator is not a state
anybody asked for, so the command restores its snapshot rather than
leaving the wreckage. |
An EMesh is the editable mesh — the one with the topology you
can walk and change. A Mesh is the flat one a stage sees. That
difference is the difference between the two register verbs.
NodeType — a kind of object
name, label | "light",
"Point Light". |
|---|---|
params | What every node of this kind carries, and what the properties panel draws. |
picture | What one looks like, or nothing when it draws nothing (D70). Derived from the parameters alone — not undoable, not saved, not replicated, and re-run when the node changes. It must have faces: the renderer draws a mesh and the wireframe it draws is the edges of those faces. |
ActionType — a menu entry
name | The identifier, for cmds and
for a key binding to name. |
|---|---|
label | "Ripple Selected". |
menu | Which menu it goes under. Empty means the Plug-ins menu itself. |
run | void(). It reaches the scene
through what the registrar gave you at load — keep those two
references. |
From a script: cmds.pluginActions() lists them and
cmds.runPluginAction("mpHost") runs one, which is how a headless
test drives a plug-in that normally has a button.
Parameters
One declaration, and everything downstream knows what to do with it: the panel draws it, the file writes it, the wire carries it.
Param angle;
angle.name = "angle"; // identifier - in cmds, in files, on the wire
angle.label = "Angle"; // what a person reads
angle.fallback = 45.0f; // the default, which also fixes the type
angle.low = -360.0f; // where a slider lives - soft, not a clamp
angle.high = 360.0f;
Param axis;
axis.name = "axis";
axis.label = "Axis";
axis.fallback = 1; // an enumeration is a number...
axis.choices = {"x", "y", "z"}; // ...plus the names of its alternatives
fallback | The default and the type. A
parameter declared float holds a float for ever after — a
saved scene names it and the reader must know what it is reading. |
|---|---|
low, high | Where the useful numbers are, not a clamp. Somebody who types 400 into the box gets 400, because a box that silently disagrees with what was typed into it is worse than a wide range. |
choices | Names for a number that stands for one of a fixed set. An enumeration is a number — stored, compared and written as one — and what makes it an enumeration is a list of names. |
type.defaults() gives every declared parameter at its default,
which is what a new stack entry or a freshly run operator starts as.
Building one
There is a CMake helper, and every line of it is a trap somebody would otherwise hit.
cmake_minimum_required(VERSION 3.20)
project(async_ripple LANGUAGES CXX)
include("/path/to/ASYNC/cmake/AsyncPlugin.cmake")
async_add_plugin(asyncRipple ripple.cpp) # core only, headless
# async_add_plugin(asyncRipple ripple.cpp panel.cpp QT) # with a panel
What it does for you:
| Links the core's headers, not its archive | Linking the archive would give the plug-in its own copy of every static
in it — the registry included — so it would register into a
Stages nothing else can see, and the host would report that
it registered nothing. Silent, and looking exactly like a bug in your
registration. |
|---|---|
| Leaves undefined symbols for the loader | Everything resolves against the process that loads you, which is where the one registry lives. |
Builds a MODULE, named .bundle on macOS |
Which is what the manager looks for. |
With QT: links the Qt inside PySide6 |
Headers from a Qt SDK to compile against, and every link by full path into the frameworks inside PySide6 — and it refuses to configure when the versions do not match. |
Drop the built file where the manager looks (beside the plug-ins that ship
with ASYNC), or point cmds.scanPlugins at your own folder.
Panels in a plug-in
A plug-in can bring a dock panel. It is a separate registrar, in the application half, and the cost is stated rather than discovered.
#include "panel.h" // async::app
ASYNC_PLUGIN_EXPORT void asyncInitializePluginPanels(async::app::PanelRegistrar& into) {
async::app::PanelType panel;
panel.name = "ripplePanel";
panel.label = "Ripple";
panel.make = [] { return static_cast<QWidget*>(new MyPanel); };
into.panel(panel);
}
A panel is a widget, a widget is Qt, and the core may not depend on Qt (D5)
— which is why this lives in the application and not beside the other
registrar. From a script: cmds.pluginPanels() and
cmds.makePluginPanel(name).
The multiplayer plug-in that ships with ASYNC is built exactly this way, and split for exactly this reason: the transport and the session know nothing about Qt, so a headless peer is possible — a machine that holds the scene, a test that runs two sessions against each other — and only the panel needs a screen.
Decisions
Why the code is the way it is, in the order it was decided. Read out of the core's journal — this is not a summary of it, it is it.