Skip to content

Architecture

How Interlude is put together, and why. This is the document to read before changing anything structural; the reasoning matters more than the shapes.

The one idea

A form is a value. Nodes build an immutable tree, a session evaluates it, a renderer draws it. Everything else follows from that.

Nodes ──build──► FormDefinition ──feeds──► FormSession ──batches──► Renderer
 (Interlude)      (immutable data)         (live state)             (WPF today)
                        │                        │
                        └──── JSON ◄─────────────┘

Because the definition is data rather than a pile of controls, it serializes, diffs, replays in a test, and could be drawn by something that is not WPF. Because the session owns all the behaviour, the interesting tests need no UI thread at all.

Layers

One code assembly, layered by namespace. Enforced by ArchitectureTests, not by project references.

Namespace What lives there WPF?
Interlude.Model The form tree: elements, options, keys, colours, spacing no
Interlude.Conditions Condition and computed-value AST, value coercion no
Interlude.Validation Rule objects no
Interlude.Runtime FormSession, dependency graph, result store, latch, host detection, the Revit selection bridge no
Interlude.Serialization JSON in and out no
Interlude.Theming ThemeDefinition, palettes, resource key names no
Interlude.Rendering IFormRenderer — the renderer contract no
Interlude.Rendering.Wpf The WPF renderer, controls, window, threading yes
Interlude The node facades no

Why one assembly

Layering by namespace rather than by project is a deliberate trade. Each extra assembly would multiply across three Dynamo builds and every package folder, and each one lands in a flat directory that Revit shares with every other add-in. A wrong reference caught by a test costs a build; a second DLL shipped to every user costs someone else's Revit install.

The folders mirror the namespaces, so if a physical split ever becomes worth it, it is mechanical.

The one exception: node icons

A package folder contains a second file ending in .dll, and it is worth being precise about why the rule bent for it and would not bend again.

Dynamo will not read node icons out of the node library. It looks for a sibling assembly named <AssemblyName>.customization.dll and pulls PNGs from a resource stream inside it. There is no attribute, no folder convention and no manifest entry that does the same job — it is that file or Dynamo's default cube on all 115 nodes.

What makes it affordable is that Interlude.customization.dll contains no types and references nothing but the netstandard facade. The rule was never really about file count; it was about what a file can collide with. A DLL with no types cannot be bound against, cannot export a version another package disagrees with, and cannot execute. It is a zip of pictures with a .dll extension because that is the container Dynamo happens to read.

So the rule is now: one code assembly, plus one resource assembly that is checked to be inert. That check is real and runs three times — VerifyResourceOnly in the project, a type count in build-all.ps1, and a types-and-references check over the packed folders in CI. The moment the icon assembly acquires a type or a package reference, it stops being a picture container and starts being the dependency the rule exists to keep out, and all three fail.

How the icons are made

115 nodes at two sizes is 230 images, and they are drawn rather than hand-authored — by Icons.cs in the preview harness, using the same offline WPF rendering that produces the documentation screenshots.

They use a family system: the plate colour identifies one of the nine categories, and a shared vocabulary of about eighty glyphs says what the node does. Glyphs are reused across categories on purpose — a calendar is a calendar whether it is Input.DatePicker, Rule.Range on a date, or Result.GetDate. In the library tree an icon is drawn at roughly sixteen pixels, which is not enough interior to separate Condition.GreaterThan from Condition.AtLeast however carefully they are drawn; colour answers "which family", shape answers "what kind of thing", and the label beside the icon does the fine distinguishing it is already there to do.

The PNGs and the compiled InterludeImages.resources container are both checked in, and CI regenerates and diffs them like it does the samples and the node help. The container is written by ResourceWriter rather than compiled from a .resx, because MSBuild cannot produce the format Dynamo reads — see the comment in Icons.cs, which records what that cost to find out.

The reactive session

FormSession is where the package earns its keep.

At construction it walks every VisibleIf, EnabledIf, RequiredIf, computed value and rule, asks each what it DependsOn(), and topologically sorts the computed values. Cycles are found here — before a window exists — because the alternative is a dialog that opens and then hangs.

From then on the contract is one-way and tiny:

control changes ──► session.SetValue(key, value)
                         ├─ recompute computed values, in dependency order
                         ├─ recompute visibility / enablement / required, parents before children
                         ├─ recompute validation
                         └──► ONE Changed event carrying the whole batch
                                       └──► renderer applies it

Controls never talk to each other. A control's entire outward contract is "my value changed", and every consequence arrives as a batch. That is what makes the renderer's job "apply batches" and nothing else, and it is why a change cascading through four fields still repaints once.

Why every pass recomputes everything

Propagate re-evaluates every computed value, condition and rule on every edit, rather than tracking which ones a particular change could have touched.

Forms are tens of fields, not millions of cells. The full pass costs nothing measurable, and it removes the entire class of bug where an incremental update misses a dependency and a field goes stale in a way that only reproduces on someone else's machine. The dependency graph still matters — it fixes the order computed values are evaluated in, and it catches cycles — but it is not load-bearing for correctness of the sweep.

If a form ever appears with enough fields for this to matter, the graph is already there to make the pass incremental. It has not been needed.

Hidden fields

A hidden field is not validated and never required. This is not an optimisation: a required field the user cannot see blocks submission with no control to fix it, which is the single fastest way to make a conditional form unusable.

Its value still appears in the results, so a downstream node reading it by name always finds it.

Immutability

Every model type is a record with init-only members.

Dynamo re-executes a graph from scratch on every change, so the tree is rebuilt rather than reconciled — there is no mutable state to get out of sync. Records give with expressions, which is what lets Behavior.VisibleIf return a modified copy of any element without a visitor per element type, preserving the concrete type for free.

The session holds the only mutable state, and it is per-showing.

Keys

Every answer is addressed by a key. Given explicitly, it is used as given; left empty, it is derived from the label by FormKeys.Slugify.

That algorithm is a versioned API contract, not an implementation detail. A saved graph reads values["wall_type"], and it keeps working only for as long as "Wall Type" keeps slugifying to wall_type. Changing the rules means bumping SlugVersion and treating it as breaking. Collisions get _2, _3 suffixes in document order, so adding a field at the bottom of a form never renumbers the fields above it.

Rendering

interface IControlRenderer {
    Type ElementType { get; }
    bool UsesFieldChrome { get; }
    FrameworkElement Build(FormElement element, RenderContext context);
    void ApplyState(FrameworkElement control, ElementRuntimeState state);
    object? ReadValue(FrameworkElement control);
    void WriteValue(FrameworkElement control, object? value);
}

Adding a control is: a sealed element record, a renderer, a line in ControlRendererRegistry.CreateDefault, and a [JsonDerivedType]. The renderer core is untouched. Two architecture tests fail if you forget either of the last two.

Resolution walks up the type hierarchy, so a subclassed element inherits its base's renderer. An element with no renderer at all draws a visible placeholder rather than throwing: a form containing one control this build has never heard of is still worth showing, and throwing would turn "this graph needs a newer Interlude" into "this graph is broken".

Label, help text, required marker and error line are drawn once, by FieldChrome. That is what makes twenty different controls look like one form, and what gives a new control correct labelling for free.

Theming

ThemeDefinition is pure data. WpfThemeApplier turns it into brushes and injects them into the form window's own Resources.

Never Application.Current.Resources. Interlude runs inside Revit and inside Dynamo — someone else's application, with someone else's styling — and writing to the application dictionary would restyle their UI from underneath them. There is a test for it.

The XAML in Themes/ consumes only {DynamicResource Interlude.*} keys, so switching light to dark is a dictionary update rather than a rebuild.

That leaves the two halves joined by nothing but a string, and a mismatch is silent: an unresolved dynamic lookup leaves the property at its default, so a typo'd key renders as merely plain rather than broken. ThemeResourceTests scans the XAML for the keys it asks for and checks each one against ThemeKeys and against what the applier actually writes.

One key is deliberately left unwritten. Interlude.ControlShadow exists only when a theme has a shadow offset; a theme without one removes the key, and the unresolved lookup leaves Effect null. Writing an invisible effect instead would give every control in every form a render layer it could not see, so the absence is load bearing and there is a test holding it in place.

Two shadows, not one, because they answer different questions. Interlude.ControlShadow is the theme's own — hard, unblurred, offset — and applies to everything. Interlude.CardShadow is what a card gets when the card asks for one with Layout.Card, and it is always present: hard when the theme offsets shadows, soft and blurred when it does not.

Error styling is driven by an inherited attached property, FieldState.HasError. The renderer flags the control it holds; the theme decides which part of a composite control turns red. That way the renderer needs no knowledge of any control's visual tree.

Threading

WindowHost handles three hosts that behave differently:

Host Scheduler runs on What happens
Revit Revit's UI thread Show directly
Dynamo Sandbox A background thread dispatcher.Invoke — the graph blocks while the dialog pumps, which is what a modal question means
Command line, scheduled run No dispatcher at all Throw with an explanation, or return defaults if headlessUseDefaults was set

Interlude never creates its own STA thread when a host dispatcher exists. A second UI thread inside Revit produces a dialog the host cannot own, cannot order correctly, and cannot reliably close.

The dialog is owned to the host window via WindowInteropHelper rather than made Topmost: an owned window stays above Revit without floating above unrelated applications. Centring is done by hand because CenterOwner only works for a WPF owner and Revit's main window is Win32.

The Revit selection bridge

Input.SelectElements lets a form send the user into the Revit model to pick, and it does so without a Revit reference. RevitSelectionBridge reaches everything by reflection over assemblies already loaded in the process: RevitServices for the active document, RevitAPIUI for PickObject/PickObjects, and RevitNodes to wrap what came back as the Revit.Elements.Element every downstream node expects. A compile-time reference would multiply the build matrix by every Revit version and end the zero-dependency rule, for three method calls; outside Revit the probe finds nothing, says why, and the control renders disabled instead of the assembly failing to import.

The pick is allowed to run because of the threading table above: in Revit the form is shown directly on Revit's UI thread, inside the API context Dynamo evaluates in, and ShowDialog's nested message loop stays inside it. Stepping the modal form aside is the delicate part, and both halves are load-bearing: the window is minimised, never hidden — hiding a window shown with ShowDialog ends the modal session — and every top-level window on the thread that ShowDialog disabled is re-enabled for exactly the duration of the pick, then re-disabled in a finally. Every window, not the owner: WPF's modal loop disables the whole thread's windows — Revit's frame and Dynamo's alike — so re-enabling just the recorded owner leaves a model that shows the picking prompt but cannot take a click.

Cancelling a pick (Escape) keeps the previous answer, spotted by the exception's type name, since Autodesk.Revit.Exceptions.OperationCanceledException is not a type Interlude can name. The reflection path itself only runs inside Revit, so the tests pin everything around it — the honest unavailability, the coercion shapes, the control's behaviour through a fake picker — and the real path is checked by running the sample graph in Revit, recorded in installing.md.

Culture

Parsing and storage are invariant; display is the user's culture.

Values arrive from Dynamo, from JSON and from text boxes typed on machines set to any locale. If parsing followed the current culture, "1,5" would mean 1.5 on one machine and 15 on another and a saved form would stop round-tripping. So ValueOps and FormJson are invariant throughout.

The inversion is at the edges: a numeric field displays and parses in CultureInfo.CurrentCulture, because someone typing "1,5" on a German machine means one and a half — and it hands the session a double, which has no culture at all.

Turkish is the case that catches naive slug code: its lowercase I is a dotless ı, so a culture-sensitive ToLower would slug "Wall ID" to something no ordinal lookup would ever match. There is a test.

Execution semantics

Concern Answer
Graph re-runs and re-opens the dialog trigger: false skips it and returns the last answers
Automatic mode opens several at once Re-entrancy latch per form: the second caller waits for the first window's result
Answers lost between runs SessionStore, keyed by formId or a hash of title plus ordered keys
Cancelling destroys remembered answers It does not — only a submitted result is stored
Cancelling returns nulls It returns every field's default, and says wasSubmitted: false
No UI available A clear exception, or defaults if explicitly opted into

Dependencies

BCL, in-box WPF, in-box System.Text.Json. That is the whole list.

No Newtonsoft — Dynamo pins its own version and fighting it is the exact class of problem this package exists to avoid. The Dynamo reference is ExcludeAssets="runtime": it supplies attributes at compile time and is never copied.

One consequence worth knowing: because Interlude's public types carry [IsVisibleInDynamoLibrary], .NET must resolve DynamoServices.dll whenever anything reflects over them — which Enum.GetNames and System.Text.Json's enum handling both do. Dynamo always has it. A standalone host needs to reference it, which is why the preview harness does.

Testing

Kind What it covers
Headless Session propagation, cycle detection, validation, slugify, cancellation, the store, the latch, host detection, JSON round-trip
STA smoke Every element builds a control, theme resources resolve, state reaches controls, no host resources touched
Architecture Layering, library visibility, one-assembly rule, renderer and schema coverage
API surface The node API against a checked-in snapshot
Samples Every example form parses, round-trips and renders

No pixel tests. What matters is that the tree is built, state reaches the controls and edits reach the session — not that a border is two pixels wide. That is what the preview harness is for.