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-CoreThe 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.
ASYNCThis 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 — cmdsScripting. 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++ — RegistrarExtending. 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.
In short: Python is the scripting surface, not the extension surface. If it has to run while geometry is being evaluated, it is C++.

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}
Paths. A node is addressed the way Maya addresses one: |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.
A command that changed nothing leaves no step (D51). Setting a value to what it already was, moving something by zero, deleting an empty selection: the verb answers happily and the stack stays where it was, because a history full of steps that do nothing is a history nobody can walk back through.

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.
Nested opens are counted, and only the outermost close ends the step — so a function that chunks its own work stays correct when it is called from inside a bigger chunk. And an open chunk nobody closes swallows everything after it into one step, which is the whole reason the block form exists.

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 aroundOrbiting 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 thingsA 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 happeningSixty 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.

Per pane, not per scene. Nothing here is saved with a document or sent to anybody else — two viewports can be lit differently. What every pane starts as is Settings › Viewport.

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.

You do not unregister. The manager takes back everything tagged with your name — every type carries a 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.
Bring your own art. A plug-in keeps its icons in a folder beside itself and never reaches into the application's set. Work out the path at load and remember it: a panel is drawn long afterwards, and by then the registrar is gone.

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

nameThe 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).
labelWhat a person reads in the Add menu: "Simple Deform", not "deform".
kindDeform 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).
paramsIn the order they are drawn and written.
runvoid(Mesh&, const Params&) — flat arrays in, flat arrays out (D3).
pluginFilled 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, labelAs above.
domainWhat 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_selectionTrue for almost everything; a "select all boundary edges" would say false.
paramsWhat the operator panel draws, and what a person can adjust after the fact.
runbool(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".
paramsWhat every node of this kind carries, and what the properties panel draws.
pictureWhat 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

nameThe identifier, for cmds and for a key binding to name.
label"Ripple Selected".
menuWhich menu it goes under. Empty means the Plug-ins menu itself.
runvoid(). 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
fallbackThe 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, highWhere 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.
choicesNames 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 price. A plug-in with a panel links Qt, and it must be the Qt inside PySide6 at the exact version this build was made against: there is one Qt in the process, and a second set of QtCore statics is a widget that never draws. Such a plug-in also stops being loadable by a headless core — its stages and operators would still come in through the core's door and stay usable everywhere, and only its interface ties it to a process that has a screen.

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.