Editor kit

The editor kit (module PulseFoundationEditor, folder Kit/) is what the asset editors of the Pulse plugins are built on, so they look and work the same: details, validation and notes tabs, a docs button, graphs with card nodes, comment boxes, auto layout and a minimap, card lists for instanced struct properties and the config workshop for assets that are put together from parts, a preview viewport with handles and labels, level editor modes with tools (level tools) and views of plugin data in the viewports. Plugin editors (and your own) use it instead of writing these parts again. The skill tree editor of PulseSkill and the piece workshop of PulseBuild are built on it.

Asset editors

FPulseAssetEditorToolkit is the base of an asset editor. It brings:

Part What it does
Details tab The asset, or the objects the subclass shows (SetDetailsObjects)
Validation tab Problems of the asset with severity icon and object; the tab title shows the count. Click a problem to go to its object
Notes tab Free text per asset, stored in the package metadata (saved with the asset, not cooked)
Validate and Docs buttons Validate runs CollectValidation and shows the tab; Docs opens the plugin’s docs page
Refresh After undo, redo and outside changes of the asset (IsObjectRelevant), on the next tick: OnRefresh, then validation

A subclass overrides what it needs:

Function Default
OnRefresh Nothing; rebuild your views from the asset
CollectValidation IsDataValid of the asset
OnValidated, OnValidationEntryClicked Nothing; details show the clicked object
IsObjectRelevant, IsWritingAsset The asset and its sub objects; never writing
GetDocsPluginName, GetDocsPage Empty: no Docs button
FillToolbar Nothing; add your own buttons

Build a layout with the tab ids DetailsTabId, ValidationTabId and NotesTabId and call InitPulseAssetEditor.

Pulse::Editor::Kit::OpenDocs(Plugin, Page) opens a docs page: in PulseHUB if it is installed (console command PulseHUB.Docs <Plugin> [Page]), otherwise the DocsURL of the plugin descriptor plus <page>.html in the browser. GetDocsUrl and MakeDocsUrl build the address only.

Validation and notes

  • Pulse::Editor::Kit::ValidateObject(Object, Entries) runs IsDataValid and adds an FPulseValidationEntry (severity, text, object) per problem. It reads the tokenized text of the issues, so messages added with FDataValidationContext::AddMessage are not empty.
  • Problems can name the value they belong to: in IsDataValid, add them with Pulse::Validation::AddIssue(Context, Severity, Text, Path) (module PulseFoundation, Validation/PulseValidation.h, editor only) instead of AddError / AddWarning. The path (“Traits[2]”, “Traits[2].Costs[0]”, a property name; MakeElementPath builds list elements) becomes the NodeKey of the entry, so graph editors show the problem on that node and select it on click. Elsewhere the message reads as before.
  • Entries have quick fixes (FPulseValidationFix: text, tooltip, Apply), shown as buttons; a fix is one undo step.
  • SPulseValidationPanel is the list widget, SPulseAssetNotes the notes widget; both work outside the toolkit too.
  • GetAssetNotes / SetAssetNotes read and write the notes of any asset.

Graph editors

FPulseGraphAssetEditorToolkit adds a graph to the asset editor:

  • Graph tab with the minimap in the lower left corner;
  • optional side panel tab (MakeSidePanel, for example totals or the path to the selection);
  • Auto Layout, Zoom to Fit, Comment (C), Minimap and Focus Path on the toolbar;
  • Focus Path highlights the selected nodes and everything they need (UPulseEdGraph::CollectHighlight) and dims the rest;
  • validation problems are shown on the node of their NodeKey (GetSelectionKey), or on the nodes whose GetDetailsObject is the object of the problem; clicking a problem jumps to the node;
  • the selection shows the objects of the nodes in the details panel and survives rebuilds.

Call InitPulseGraphEditor with the graph and a layout name (change its version suffix when the tabs change).

The graph

UPulseEdGraph is a transient mirror of the asset: the asset is the only source of truth, the graph is built from it and every edit changes the asset first. A subclass:

  1. implements BuildGraph (create nodes with CreateTransientNode, link their pins);
  2. returns the editor-only comment array of the asset in GetCommentStorage (or nothing, then there are no comments);
  3. writes edits inside an FPulseGraphEditScope: it opens a transaction, calls Modify on the asset, marks the graph as writing (the editor ignores its own changes) and calls OnAssetEdited and PostEditChange at the end.
bool UMyEdGraph::RemoveEntry(UMyEntry* InEntry)
{
	{
		const FPulseGraphEditScope scope(this, LOCTEXT("RemoveEntry", "Remove Entry"));
		GetMyAsset()->Entries.Remove(InEntry);
	}
	RemoveNode(FindNode(InEntry));
	return true;
}

The base also has GetDirection (top to bottom or left to right), GetAccentColor, GetLinkLabel (text on a link, e.g. “Rank 2”) and AutoLayout. GetLinkStyle returning EPulseGraphLinkStyle::Arrows draws the links of flat nodes like a state machine: straight arrows from the edge of one node to the edge of the other, links in both directions side by side with their labels beside them, so nothing crosses wherever the nodes stand. The nodes then show no pins; a link is dragged from the edge of a node onto another node (UPulseEdGraphSchema::ResolveArrowPins turns the drop into the input of that node; call it first in CanCreateConnection and TryCreateConnection).

Store comments in the asset as editor-only data:

#if WITH_EDITORONLY_DATA
	UPROPERTY()
	TArray<FPulseGraphEditorComment> EditorComments;
#endif

FPulseGraphEditorComment (EditorData/PulseGraphEditorComment.h) is in the runtime module PulseFoundation, so runtime assets can hold it. Moving, resizing, renaming and recoloring a comment writes it back (UPulseEdGraphNode_Comment).

Nodes

UPulseEdGraphNode is drawn by SPulseGraphNode in the look the graph picks with GetNodeStyle:

  • Flat (default): a header bar in the node color with a white icon and the title (the text turns dark on light colors), a dark body with the lines, triangle pins (left and right, or on top and at the bottom in top to bottom graphs; pin names only when a side has several pins), a gold outline when selected and a red or orange one for validation problems.
  • Card: accent strip, big icon, title, subtitle, pips, summary lines and bar pins.

Fill it by overriding:

Function Content
GetDetailsObject Object of the asset the node stands for (details, double click, validation)
GetCardTitle, GetHeaderIcon Title (default: node title); white icon of flat headers, tinted with the header text color
WritePosition Store the node position in the asset (Modify only, no PostEditChange)
GetCardSubtitle, GetCardIcon, GetCardFallbackIcon Second line, icon texture, icon brush when there is no texture
GetCardAccentColor, GetCardPipCount, GetCardPipText Color, number of dots, text next to them
GetCardLines Lines (FPulseGraphCardLine: label, text, color; FPulseGraphCardLine::Rich with Graph::Muted("After") for quiet words)
GetCardWidth, GetDefaultSize Card width; size for the layout when the widget size is unknown
GetCardHeaderNote Short text after the title in the header of flat nodes, cut off with dots (compact nodes)

Give the classes of your nodes white SVG icons for flat headers (for example Resources/Icons/White/, registered as ClassIcon.<Class>.White), so they read on every header color.

Pins of flat nodes report a double click to UPulseEdGraph::OnPinDoubleClicked; graph editors can show a second tab next to their left panel (MakeLeftPanelExtra) and a tab next to validation and notes below the graph (MakeBottomExtra, brought to the front with ShowBottomExtra).

Reroute nodes (UPulseEdGraphNode_Reroute, drawn as the knot of the engine) lead links around other nodes. A double click on a link calls UPulseEdGraph::InsertReroute; graphs that store reroute nodes override it together with RemoveReroutes and OnRerouteMoved and create the nodes again when they build (the config workshop does). Graphs without an override ignore the double click.

Schema and drawing

UPulseEdGraphSchema adds Add Comment to the context menu, deletes comments and draws the links with FPulseGraphConnectionDrawingPolicy: thin light links in flat graphs (the triangle pins show the direction; the focus path is white), accent links with arrows and links spread over bar pins in card graphs, a label chip near the input end and faded links outside the focus path. Derive the schema of your graph from it and call Super in GetGraphContextActions and SafeDeleteNodeFromGraph.

Layout

Pulse::Editor::Kit::Graph::ComputeLayeredLayout is the layout as pure logic (specs PulseFoundation.Editor): layers by the longest chain of links, helper nodes on long links, barycenter sweeps that keep the order with the fewest crossings (starting from the current positions), and placement near the middle of the neighbors without overlap. UPulseEdGraph::AutoLayout runs it with the real card sizes and fits comment boxes around the nodes they contained.

Card lists

Lists of instanced structs and instanced objects (traits, rules, costs, conditions, grants) are shown as cards in the details panel instead of the engine rows. A card shows its type at a glance and only what you changed:

  • a strip and a tile in the color of the category of the type. The category is the meta Category of the struct or class (the last part after |); the tile shows the icon of the meta CardIcon (a brush of the editor style, e.g. Icons.Heart), else the first letter of the type. Types without a category are grey;
  • the type name bright and, collapsed, the changed values as pairs of a grey field name and a bright value (“Max 120 Loss Per Use 0.5”). A flag that is on shows its name, one turned off says “off”, lists name their first two entries and count the rest (“Abilities GA_Shoot, GA_Slash +1”);
  • expanded, the category and one sentence of what the type does (the first sentence of its tooltip, so of the comment above the struct) instead of the summary, and below it the values in rows with a fixed name column. Changed values have a bright name with a blue dot, the reset arrow shows when the mouse is over the row.

Traits of an item: one frame, cards in the color of their category, the weapon open

Under the mouse a card gets a brighter outline and shows a grip (drag the card to sort the list; a blue gap shows where it lands, and the move is one undo step), Duplicate (lists that allow a type more than once) and Delete. The menu (⋮) has Change Type, Move Up and Move Down. Click the header to open or close a card.

The states a card can be in:

Entry without a type and a type twice, several assets selected, an empty list

  • No type: a yellow strip, “No type” with Choose type in the card and the hint that the entry does nothing yet.
  • A type twice in a list that holds each type once: the later card is marked, says why and has a Delete button; only the first one is used.
  • Several objects selected: “N items selected”, the cards show their summaries only and values that differ are “Mixed”. Select one object to edit the values.
  • Empty list: what belongs in the list (the first sentence of its tooltip) and a button “Add ". The entry name comes from the list name ("Costs" → "Add cost"); set `meta = (PulseItemName = "drop")` on the property where the singular is different.
  • Narrow panels: the summary fades out at the right edge and the name column gets narrower (80 to 170 pixels).

+ Add (and Choose type) open the type picker: types grouped by category in the colors of the cards, each with its tile and one sentence (the full text is in the tooltip). Typing searches names, categories and descriptions, the arrow keys move, Enter picks, Esc closes. In lists that hold each type once, types in the list are greyed out (“in list”).

The type picker of item traits

The colors of the common categories (Combat, Equipment, Condition, Basics, Loot, Costs, Rewards, Logic, Context, Blueprint, Construction, Placement, Ownership, Abilities, Effects, Tags, Use, Limits, Items, Attributes, Interaction, Quests, Dialogs, Skills) can be changed under Editor Preferences > Pulse > Foundation > Cards (UPulseCardSettings, saved per user). Every other category gets a color of a fixed palette by its name, the same in every project and session. Give the types of a list categories that tell them apart, and an icon where one fits:

/** Binds the item to the player who picks it up. It cannot be dropped or traded. */
USTRUCT(BlueprintType, meta = (DisplayName = "Soulbound", Category = "Basics", CardIcon = "Icons.Lock"))
struct FMyItemTrait_Soulbound : public FPulseItemTrait

Register the base structs in an editor module; the runtime structs stay unchanged:

// StartupModule of the plugin's editor module
Pulse::Editor::Kit::Cards::RegisterBaseStructPath(TEXT("/Script/PulseInventory.PulseItemTrait"), true); // each type once
Pulse::Editor::Kit::Cards::RegisterBaseStruct(FMyRule::StaticStruct());

Lists of instanced objects (UPROPERTY(Instanced) TArray<TObjectPtr<UMySkillGrant>> with EditInlineNew classes) work the same way: register their base class with Cards::RegisterBaseClassPath(TEXT("/Script/PulseSkill.PulseSkillGrant")). The type picker lists the classes that can be created inline; a new entry is an object owned by the object that holds the list. Only registered Pulse types are affected, never lists of the engine or of other plugins.

Card containers

Lists at the top level of an asset, component or settings class are shown as card containers (SPulseCardList): one dark frame with the title of the list, the number of entries, Collapse all / Expand all and + Add, the cards inside it. Which cards are open is remembered for the editor session; a new entry starts open.

Changes inside a card go through the handle of the entry, so undo, the dirty state and PostEditChangeProperty of the asset work as with the engine rows. Instanced object properties (Instanced, EditInlineNew classes) get the class picker of the kit: pick a class (Blueprints too), clear the object, open its Blueprint or Create New Blueprint…; the values of the object show below it. Value names show the tooltip of their property. The values are drawn by SPulseValueRows (Kit/SPulseValueRows.h): compact rows of a struct (through an IStructureDataProvider) or of objects, with nested values, reset arrows and edit conditions; editors can use it wherever a details panel is too big. The categories of objects follow PrioritizeCategories of their class (UCLASS(meta = (PrioritizeCategories = "Domain Simulation Bake"))), as in details panels; without it the engine puts categories with CallInEditor buttons first, sorted by name. The settings of level tools are drawn this way.

Register the classes whose lists become containers, by path:

Pulse::Editor::Kit::Cards::RegisterCardClass(TEXT("/Script/PulseInventory.PulseItemDefinition"));
// ShutdownModule
Pulse::Editor::Kit::Cards::UnregisterCardClass(TEXT("/Script/PulseInventory.PulseItemDefinition"));

A class has one details customization per name. Classes that have their own customization call Cards::CustomizeCardArrays(DetailBuilder) in CustomizeDetails instead. Editors can also put an SPulseCardList into their own panels with the handle of an array property.

Lists of instanced structs nested in structs (for example the costs inside a construction trait) and lists of classes without registration get card rows: each entry is a card header with its values below it. Lists of instanced objects nested in structs (the grants of a skill rank) keep the engine rows for now.

A single property can opt in with meta = (PulseCards) and out with meta = (NoPulseCards); the console variable Pulse.Editor.Cards 0 switches the cards off. The picker leaves out structs with the meta Hidden, Deprecated or Abstract. Pulse.Editor.ShowDetails [width] <object path>... opens a details panel of several objects, to check how your lists look with a multi-selection.

The summary comes from, in this order:

  1. a provider registered with Cards::RegisterSummary(Struct, Provider) (also used for child structs), shown as free text;
  2. the struct meta PulseSummary, e.g. meta = (PulseSummary = "{Amount} points, {RefundPercent}% back");
  3. the values that differ from the defaults of the struct, as pairs (see above).

Own widgets get the same parts from Kit/PulseCards.h: GetTypeColor, GetCategoryColor, GetTypeCategoryName, GetTypeLetter, GetTypeIcon, GetTypeDescription, GetFirstSentence, MakeAddLabel, and MakeSummaryParts with MakeSummaryMarkup (rich text with <muted> names) for the pairs. The entry nodes of the config workshop use them too: the header has the color of the category, collapsed nodes show the pairs, open nodes the category and the sentence above their values.

PulseFoundation registers conditions, costs and rewards, the lists of every plugin. PulseBuild registers its traits, rules, permissions, piece sources, states and placement modes; PulseCore its item and inventory traits and the combat extension points. Card containers: the build piece definition, build component and build settings; the item definition, inventory component, inventory layout preset, recipe, interactable component, combat component and settings, melee and ranged attack abilities and the inventory UI settings; the skill definition and the skill progression.

Config workshop

The config workshop is an asset editor for assets that are put together from parts: traits, rules, costs, sockets, grants. It opens as a tree (see The tree) in two columns: the asset on the left like the StateTree editor, the chosen list in the middle as cards, a table or a stack with the values of the selection, the preview on the right. Tree | Graph in the toolbar switches to the graph, the other view, with the same selection; the choice is kept per asset class and user. The tree needs no palette, outline, details or tabs below: it closes them, and the graph opens them again.

The graph is not a flow. It opens on the overview: one box per property category (“Piece”, “Look”, “Placement”), each with its section node (the plain values of the category) and the entries of the lists in that category. A section whose category has the name of a list is called “ Settings". The asset itself has no node: its name and the values named in `SummaryProperties` stand in the bar above the graph. Every list opens as a **graph of its own** (see [Overview and graphs of lists](#overview-and-graphs-of-lists)), so a big asset never becomes one wide tree.

The former view, one tree from a main node with a pin per entry, is still there: Layout > Classic Tree (option bClassicGraph of the workshop preferences). The layout options, the focus button and collapsible lists below belong to it.

Piece workshop of PulseBuild: palette, overview with a box per category, 3D preview and details

  • Left: the palette with every type that can be added, by list and category. Drag a type onto a + pin (or onto a free spot of the graph: it goes into the list of its palette group and stays where you dropped it); double click adds it to that list. In lists that hold each type once, types already in the list are greyed out. Lists of objects also list the Blueprint classes of their base class (loaded or not) and offer Create New Blueprint…: a save dialog creates a Blueprint of the base class next to the asset, adds it and opens it.
  • Middle: the graph. A node shows its list and a short summary (the same summaries as the cards); the arrow in its header opens the values inside the node. Drag the link of an entry onto a pin of another list to move it there (or onto another pin of its own list to sort it). Delete removes entries, Ctrl+D duplicates, Ctrl+C / Ctrl+V copy entries as text, also into another asset. The context menu of a node has Show Values, Move Up, Move Down, Duplicate, Change Type and Remove.
  • Right: the preview of the plugin (optional) and the details of the selected node: the values of an entry or a section, or the plain values of the asset for the main node. With several nodes selected it edits several objects together, or asks to select one node. The details customization of the asset class (with its own preview, for example) is not used here; the workshop has the preview on the right.
  • Below: validation and notes.

The tree

World definition in the tree: sections and lists on the left, the lists of a world type as cards with its values in the middle, the map on the right

  • Left, the tree: a tinted row per section (category), a row per list with its number of entries, its view and its problems, a row per entry with the type tile in the color of its category, the name, the type and the summary, and below it the summary and up to two lines of key values (a node provider gives them with GetTreeLines, else the changed values the summary leaves out; Compact hides them). References to other assets are italic with an arrow that opens them. Problems are counted at the entry and summed at every row above it, so a closed branch shows them too; their number in the head of the tree opens the Validation tab with all of them. The search keeps the parents of every match; the eye dims every section but the one of the selection. Drag entries to sort them or to move them into another list of the same type (one undo step); the context menu has Add, Show as Cards / Table / Stack, the entries of the plugin (AddListMenuEntries), Duplicate (Ctrl+D), Delete (Delete), Change Type and Show in Graph. Open and closed branches are kept per user, not in the asset.
  • Middle: the way back (crumbs “Generator > Layers > Mountains”) and the list of the selection in its view; an entry with lists shows its values and then its lists, a section its problems, plain values and lists. The values of the selected entry stand where it is: in its card, or below the table or the stack, with its problems and their fix buttons above them and the values of the entry it sits in folded below. Choosing another entry of the same list only changes these values; the rest stays where it is. Every list has Cards | Table | Stack (a dot marks the view the plugin chose) and an add row with three types that fit and More….
    • Cards: a card per entry with tile, name, type, summary, the lists inside it as chips (a click opens them); the selected card shows its values, the arrow opens those of others.
    • Table: a row per entry, a column per plain value, edits go to every selected row; a red dot marks rows with a problem. Compare with N assets in Pulse Tables opens the tables of the class. A list of instanced structs with the meta PulseTableColumns adds a column per extension type with one value (the surface type of the voxel bridge on the ground layers of Pulse World); typing into an empty cell adds the extension.
    • Stack: rows in order with a drag handle, an eye for types with a bEnabled value, S (solo: only this entry in the preview, IConfigPreview::OnSoloChanged; the asset does not change), name, summary and a value of the node provider on the right (GetStackValue, time per layer for example).
  • Right: the preview (and its Live Test tab). A click into the preview selects the entry it shows (the preview calls the function it got with the path; a plain value of the asset selects its section).

Layers of a generator as a stack with eye and solo, the values of the selected layer below it, the preview on the right

The plugin picks the view of each list in FConfigAssetOptions::ListViews (by the path of the list without indices, “Layers”, “Biomes.GroundLayers”) and DefaultListView; without them lists of one fixed struct are tables, all other lists cards. A user’s choice wins and is kept per class.

options.ListViews.Add(TEXT("Layers"), Pulse::Editor::Kit::Config::EListView::Stack);
options.ListViews.Add(TEXT("Sockets"), Pulse::Editor::Kit::Config::EListView::Table);

Sockets of a piece as a table with the values of the selected socket below it, the 3D preview and its live test on the right

Pulse.Workshop <Asset> Mode Tree|Graph, ListView <List> Cards|Table|Stack, Select <Path>, Solo <Path> and Compact 0|1 drive an open workshop for scripted checks and pictures.

Overview and graphs of lists

The bar above the graph has a tab per open view (the overview, every opened list, every opened group; the cross closes one), then a head row: in the overview the asset with its main values and a note of the asset type (FConfigAssetOptions::MakeHeaderNote), in a list the way back (arrow and crumbs “Asset > Traits > Costs”) and the counts. On the right: Lanes / Table for lists, Compact / Full cards and a search that dims every node that does not match.

Graph of the traits of a wall: a lane per trait with the lists inside it as chips and entries

  • Overview: a list with more entries than Overview Max Entries (default 12) is one node that opens its graph; shorter lists show their entries. An entry shows the lists inside it as chips (green: open the graph of the list with that entry selected; violet: the list has lists itself, open its own graph). A double click on an entry opens the graph of its list. Empty lists show Start with buttons with the first types of the list and all types…. The boxes stand in Overview Columns columns (2 or 3, Layout menu).
  • Graph of a list: one lane per entry: the entry, then every list inside it in a row (its chip, then its entries; a list one level deeper is only a chip that opens its own graph). References to other assets are dashed links. **Add to ** at the end adds an entry.
  • Table: the entries of the list as rows, a column per plain value. A value typed into a cell goes into every selected row, as one undo step.
  • Collapse to Graph (Ctrl+G, context menu): a list, an entry with everything inside it, or several entries of one list one after the other become one group node in the overview or the graph of the list; a double click (or Open as graph) opens the group as a view of its own, Expand Here puts the nodes back, Rename… names it. The group node shows how many nodes it holds and how many of them have problems. Groups are stored with the layout and follow their entries when entries are added, removed or moved; collapsing, expanding and renaming are undo steps and change nothing in the asset.
  • The Outline lists sections, lists and groups; a click opens the view that shows the item (a list opens its graph, a group its view). Rows show how many problems the validation found inside them.
  • Positions are kept per view: a node moved in the graph of a list keeps its place there only. Every view remembers where it was looked at; a view opened for the first time is fitted into the window.

Pulse.Workshop <Asset> View <View> | Table <0|1> | Collapse <Key>... | Expand <Group id> | Search <Text> | Select <Key>... changes an open workshop from the console (views are O, L:<list path>, G:<group id>), for scripted checks and pictures.

Node headers, pins and value names have tooltips (the description of the type, the list and the property). Nodes inside a selection rectangle are highlighted while it is drawn.

The tree is laid out from left to right and moves along when a node opens or a list changes; nodes you move keep their place. A double click on a link adds a reroute node, a point that leads the link around other nodes; drag it to move it, Delete removes it. Auto Layout lays out the whole tree again (without reroute nodes). Positions, comment boxes and reroute nodes are stored in the package metadata of the asset, so nothing changes at runtime; they move with their entries when entries are added, removed or moved. Every edit is one undo step, also moving nodes, comments and reroute nodes.

Layout options (toolbar button Layout, all of them also in Editor Preferences > Pulse > Foundation > Config Workshop, set per person):

Option What it does
Compact Nodes Collapsed entry nodes take one line: the title and the summary in the header
Lists Side by Side Each list of the main node gets a column of its own; the graph grows wide instead of high
Wrap Long Lists Lists with more entries than Entries Per Column (default 5) are put into several columns
Collapsible Lists Lists can be collapsed into one stack node (“Sockets (6)” with the first titles); the arrow of the stack node expands it, the context menu of a node collapses or expands its lists, Collapse All Lists / Expand All Lists in the Layout menu; a new entry expands its list; the state is kept per asset in the layout

Navigation in big assets (also switchable in the editor preferences):

  • the Outline tab next to the palette lists every section, list and entry of the asset as a tree with search, including the lists inside entries; a click selects the node and moves the graph to it, a list shows all its entries; collapsed lists on the way are expanded. The outline follows the selection of the graph;
  • the focus button of the toolbar (All Lists / Only Traits) shows a single list of the main node and hides the sections and the other lists (for this editor session);
  • a double click on a pin of the main node (or of an entry) selects all entries of that list and fits them into the view.

Register the classes that open in the workshop in the editor module; double click on an asset of the class (or of a child class) then opens the workshop:

// StartupModule
Pulse::Editor::Kit::Config::FConfigAssetOptions options;
options.Lists = { TEXT("Sockets"), TEXT("Rules"), TEXT("Traits") };   // empty: every list
options.SummaryProperties = { TEXT("Category"), TEXT("Mesh") };        // named on the main node
options.Color = FLinearColor::FromSRGBColor(FColor(232, 156, 62));
options.DocsPlugin = TEXT("MyPlugin");
options.DocsPage = TEXT("editor");
options.MakePreview = [](UObject* InAsset, TFunction<void(const Pulse::Editor::Kit::Config::FValuePath&)> InSelectPath)
{
	return MakeShared<FMyPreview>(InAsset, MoveTemp(InSelectPath));   // IConfigPreview, optional
};
Pulse::Editor::Kit::Config::RegisterConfigAsset(TEXT("/Script/MyPlugin.MyDefinition"), options);

// ShutdownModule
Pulse::Editor::Kit::Config::UnregisterConfigAsset(TEXT("/Script/MyPlugin.MyDefinition"));

Lists of instanced structs, lists of instanced objects (EditInlineNew classes), single instanced values (one pin with at most one node) and lists of a fixed struct type (sockets, tiers: + adds an entry with default values) all work. Sections and HiddenCategories choose the section nodes, SectionNames renames them, HiddenProperties leaves properties out. Config::RegisterEntryTitle(Struct, Provider) names entry nodes; the provider gets the asset, the path, the type and the memory of the entry, so it can name an entry by its place (“Tier 1: Stone” for the first integrity profile of a piece). Without a provider a Name or DisplayName value or the type name is used.

Pulse::Editor::Kit::Config::RegisterEntryTitle(FMyTierData::StaticStruct(), [](const Pulse::Editor::Kit::Config::FEntryTitleContext& InContext)
{
	return FText::Format(INVTEXT("Tier {0}"), FText::AsNumber(InContext.Path.GetIndex() + 1));
});

A preview (Config::IConfigPreview) gets the selection of the graph or the tree (OnGraphSelectionChanged with the paths of the selected entries) and can select an entry itself with the function it got (for example when a socket is clicked in the 3D view); OnSoloChanged tells it the solo entry of a stack. MakeTestPanel adds a Live Test tab next to the details (GetTestPanelLabel names it), so the preview stays visible while a test runs: sliders, a second asset to try the asset with, and so on. AddListMenuEntries adds entries to the context menu of a list (its pin, the main node), for tools that work on the whole list (PulseBuild: “Generate Sockets…” on the sockets). Graph editors in general get such a tab with FPulseGraphAssetEditorToolkit::MakeDetailsExtra. Entries are addressed by paths (FValuePath, “Traits[2].Costs[0]”), see Kit/Config/PulseConfigPath.h.

References to other assets

References to other assets (object and soft object properties, single or in lists) are reference nodes: the thumbnail and the name of the asset, its list and the summary values of its own workshop, linked with a dashed line (the asset is not part of this one). By default every reference to an asset whose class has a workshop gets reference nodes, also inside entries; FConfigAssetOptions::References names more (any property of that name), and a property named in Lists is always shown. Other references stay values of their node.

  • Drag assets from the content browser onto a reference pin (+ of a list, or a single reference), onto a node with reference lists or into the graph: they go into the first list (or single reference) that takes their class. Dropped on a reference node, the asset replaces it. The hover text says where they go.
  • The palette and the menu of a reference pin list the assets of the class that are not in the list yet.
  • Double click opens the asset (in its workshop if it has one); the context menu also finds it in the content browser.
  • Delete removes a reference from its list, or clears a single reference; the asset itself stays.
  • Selected, the Details tab shows a picker for the asset.

Config::GetReference, GetReferencePath and SetReference read and set references by path.

Validation, quick fixes and relations

The Validation tab of the workshop lists the problems of IsDataValid and the checks of the workshop, each on the node it belongs to (a click selects the node, expanding collapsed lists on the way; plain values select their section), with quick fixes as buttons:

Check Fix
An entry without a type, a reference to nothing in a list Remove
A type twice in a list that holds each type once Remove (the second one)
A value outside its ClampMin / ClampMax (entries and plain values of the asset) Set to the nearest allowed value
An entry needs a type the asset does not have (RegisterRequires) Add it to the first list that takes it
Entries of two types do not work together (RegisterConflicts) Remove the other one

When IsDataValid reported a problem with the same path (Pulse::Validation::AddIssue), the fix goes to that problem instead of a second one, so plugins keep their own texts. Relations are registered per type in the editor module; RegisterSuggests puts the type under Fits With at the top of the palette and offers Add in the menu of the node while it is missing:

using namespace Pulse::Editor::Kit::Config;
RegisterRequires(FMyTrait_Decay::StaticStruct(), FMyTrait_Health::StaticStruct(), INVTEXT("Decay takes health away."));
RegisterSuggests(FMyTrait_Construction::StaticStruct(), FMyTrait_Costs::StaticStruct());
RegisterConflicts(FMyTrait_Static::StaticStruct(), FMyTrait_Physics::StaticStruct());
// ShutdownModule: UnregisterRelations(FMyTrait_Decay::StaticStruct()); ...

Checks of the plugin go into FConfigAssetOptions::Validate. The context adds problems on nodes and makes fixes that edit through the workshop (undo, layout): MakeAddFix, MakeRemoveFix, MakeSetFix; AddFix hangs a fix on a problem IsDataValid reported at a path:

options.Validate = [](Pulse::Editor::Kit::Config::FConfigValidationContext& InContext)
{
	const UMyDefinition* asset = Cast<UMyDefinition>(InContext.GetAsset());
	const Pulse::Editor::Kit::Config::FValuePath path(TEXT("Traits"), 0);
	InContext.AddFix(path, InContext.MakeSetFix(path, TEXT("Stages"), TEXT("1"), INVTEXT("Pay at Once")));
};

Templates

Assets of a class often share parts: every wall has the same costs, construction and health, every potion the same use. A config template (UPulseConfigTemplate, Kit/Config/PulseConfigTemplate.h) holds such parts: entries of lists (also single instanced values such as a placement mode) as text in the copy and paste format of the workshop, and plain values. Templates are editor only and never cooked.

The Templates menu of the workshop:

  • Save as Template puts every entry of the lists into a new template TPL_<Name> next to the asset (or into the template folder of Editor Preferences > Pulse > Foundation > Config Workshop). Rename or move it like any asset; its Description shows where templates are picked.
  • Save Selection as Template takes the selected entries and the values of the selected section nodes (select the section of the category, for example).
  • Below are the templates of the class and its parents. Applying one is a single undo step: lists that hold each type once get the values in their entry of that type, single values are replaced, values the asset has already are skipped, everything else is added at the end. A notification tells what happened (“3 added, 1 replaced, 2 already there”).

The same functions are there for code (the creation wizard uses them):

using namespace Pulse::Editor::Kit::Config;
UPulseConfigTemplate* preset = CreateTemplateAsset(MyAsset);              // TPL_<Name>, package dirty
FillTemplate(*preset, MyAsset, GetTemplateEntries(MyAsset), { TEXT("Category") });
for (const FAssetData& found : FindTemplates(UMyDefinition::StaticClass()))   // by SortOrder, then name
{
	const FTemplateResult result = ApplyTemplate(OtherAsset, *CastChecked<UPulseConfigTemplate>(found.GetAsset()));
}

ApplyTemplate edits through FEditScopes without a transaction of its own; open one around it for a single undo step (UPulseConfigGraph::ApplyConfigTemplate does that in the workshop and keeps the layout).

A plain value that is a struct (a whole profile, for example) is stored against the defaults of the struct and set as a whole: members the text leaves out get their defaults, so zero and false values carry over, and a text that does not import leaves the value as it is.

Creating assets

A class that is a Create entry of the plugin’s workflow needs no factory: **Add > Pulse >

** in the content browser creates its assets with the Pulse asset factory. Without a workflow, a plugin gives its class a factory of the kit: ```cpp UCLASS() class UMyDefinitionFactory : public UPulseConfigAssetFactory // Kit/Config/PulseConfigAssetFactory.h { GENERATED_BODY() public: UMyDefinitionFactory() { SupportedClass = UMyDefinition::StaticClass(); } }; ``` A new asset opens in its workshop together with the **creation wizard**, a window next to it (not modal): a template of the class (each with its description and content), the mesh, rows of the plugin and "Render the icon from the mesh". **Create** applies the template, then the mesh, then the rows of the plugin in one undo step and renders the icon; **Skip** keeps the asset empty. Templates > **Set Up with Wizard...** opens it again later. The options of the class say what the wizard asks for: ```cpp options.WizardMeshProperty = TEXT("Mesh"); // mesh row, and "Create " for static meshes options.MakeWizardExtension = [](UObject* InAsset) -> TSharedPtr<Pulse::Editor::Kit::Config::IConfigWizardExtension> { return MakeShared(); // MakeRows, OnTemplateChosen (suggest a choice), Apply }; ``` With a mesh row, the context menu of static meshes has **Create **: it creates `DA_` next to the mesh with that mesh and opens it with the wizard. `Config::ApplyWizardChoices` and `Config::CreateAssetFromMesh` do the same from code. For editor scripts (Editor Utility Blueprints, Python, MCP), `UPulseConfigTemplateLibrary` has `CreateTemplateFromAsset` (fills the template at a path again when it exists, so scripts can run more than once), `ApplyTemplateToAsset`, `RenderAssetIcon`, `CreateAssetFromMesh` and the functions of the [variants](#variants), [tables](#tables) and the [comparison](#compare): ```python lib = unreal.PulseConfigTemplateLibrary wall = unreal.load_asset("/MyPlugin/Examples/DA_Wall") preset = lib.create_template_from_asset(wall, "/MyPlugin/Templates", "TPL_Wall", ["Rules", "Traits"], ["Category"], unreal.Text("A wall"), 1) unreal.EditorAssetLibrary.save_loaded_asset(preset) ``` ### Variants A **variant** is an asset that inherits the values of another asset, its parent: a stone wall is the wall with other costs and more health. Change the costs of the wall and every variant follows, except the values a variant changed itself. Variants exist only in the editor: a variant is a full copy with all its data, so the game and cooked builds see an ordinary asset and pay nothing for it. The link lives in the package metadata of both assets. ![Workshop of a variant: inherited cards faded, the overridden placement settings drawn as usual](/PulseFoundation/images/config-variants.png) - **Create Variant** (context menu of the root node, or of one asset of a workshop class in the content browser) creates `_Variant` next to the asset and opens it. Every value is inherited at first. - The root node says **Variant of DA_Wall** in a variant and **N variants** in a parent. Inherited cards are faded and marked "inherited"; cards with values the variant changed are drawn as usual. - The context menu of the root node of a variant has **Open Parent**, **Update from Parent**, **Reset All to Parent** and **Unlink from Parent** (the asset keeps its values and stands alone). Changed cards have **Reset to Parent**: the value of the parent again, inherited from then on. How values are compared: a value is inherited while it is the same as what the parent had at the last update; once the variant changes it, it is an override and stays. Each plain property counts on its own (`Mesh`, `RotationStep`). Lists that hold each type once count per type, so a variant can change its costs trait and still inherit construction and health; when the parent gets a new type, the variant gets it too, and a type the parent removes goes from the variant unless the variant changed it. Other lists (sockets, materials) and single instanced values (a placement mode) count as a whole. When variants follow: - Every change of the parent, also undo and redo, goes to its variants right away. Variants that are not loaded are loaded; the changed ones are dirty and show up in the save dialog together with the parent. - Opening the workshop of a variant takes over what the parent got meanwhile (for example from version control), and Update from Parent does it by hand. - Variants of variants work; a variant can not become the parent of its own parent. - Renaming or moving a parent or a variant keeps the link. A deleted variant drops out of the list of its parent; a variant whose parent is deleted keeps its values. **Duplicate** gives a standalone copy without links (package metadata is not duplicated); use Create Variant for a variant. From code (`Kit/Config/PulseConfigVariants.h`) and scripts: ```cpp using namespace Pulse::Editor::Kit::Config; UObject* stoneWall = Variants::CreateVariant(Wall); // or Variants::MakeVariant(Child, Parent) const bool bInherited = Variants::IsInherited(stoneWall, TEXT("P:RotationStep")); Variants::ResetToParent(stoneWall, TEXT("E:Traits:/Script/PulseBuild.PulseBuildPieceTrait_Costs")); ``` ```python lib = unreal.PulseConfigTemplateLibrary variant = lib.create_variant(wall) # also make_variant, unlink_variant, get_variant_parent, get_variants print(lib.get_inherited_values(variant)) # ["E:Traits:/Script/...", "L:Sockets", "P:Mesh", ...] lib.reset_variant_to_parent(variant, "P:RotationStep") # one undo step; an empty key resets everything ``` Keys are `P:` for plain values, `E::` for entries of lists that hold each type once, `L:` for other lists and `S:` for single instanced values (`Variants::GetValueKey` gives the key of a path). `UpdateFromParent` and `ResetToParent` edit through `FEditScope`s without a transaction of their own. ### Tables **Pulse Tables** shows the assets of a class of the workshop as rows and values inside them as columns: all walls with their category, rotation step, health and payment stages side by side, to balance them without opening each one. ![Pulse Tables with the building pieces of PulseBuild](/PulseFoundation/images/config-tables.png) - Open it with **Table** in the toolbar of the workshop (every asset of the class, the asset selected), **Open in Pulse Tables** in the context menu of the content browser (with several assets selected only those), or **Tools > Pulse Tables**. The class picker on the left switches between the classes of the workshop. - Cells are edited in place: text for numbers, names and texts, a check box for bools, a menu for enums, and for references to assets the name with "use the asset selected in the content browser" and "browse". Gameplay tags are their names (`Pulse.Build.Category.Walls`, several separated by commas). A cell without value (the asset has no entry of that type) shows a dash. - **Several assets at once**: select rows (Ctrl or Shift click) and edit a cell in one of them; the value goes to every selected row as one undo step. - Click a header to sort (numbers by value, missing values last); the search finds asset names and values. - **Columns** lists the plain values of the class and, in sub menus per list and type, the values of the entries the assets have (the costs trait, the health trait, the placement mode). Any column path can be typed in. The menu of a header moves or removes its column. Each class keeps its columns; **Presets** saves and applies named sets of columns (Editor Preferences > Pulse > Foundation > Config Workshop, per user). - **Export CSV...** writes the rows shown (first column: the object path of the asset), **Import CSV...** reads such a file back as one undo step: changed cells are set, empty cells skipped, unknown assets and values that do not fit are reported. The assets are not saved. A column path names a value from the asset down: | Path | Value | | --- | --- | | `RotationStep`, `Bounds.Max` | A property of the asset, a member of a struct | | `Traits:Costs.PaymentStages` | The entry of a type in a list. The type is the struct or class name, its display name or the part after the last `_` (`PulseBuildPieceTrait_Costs`) | | `Sockets[0].Name`, `Tiers[1].Health` | An entry of a list by index | | `Placement.CellSize`, `Placement:Grid.CellSize` | A value inside a single instanced value, of any type or of one type | From code (`Kit/Config/PulseConfigTable.h`) and scripts: ```cpp using namespace Pulse::Editor::Kit::Config; FString stages; Table::GetCell(Wall, TEXT("Traits:Costs.PaymentStages"), stages); Table::SetCell(Wall, TEXT("Traits:Costs.PaymentStages"), TEXT("3")); // FEditScope, open a transaction around it const FString csv = Table::ExportCsv(Table::FindAssets(UMyDefinition::StaticClass()), { TEXT("Category"), TEXT("Traits:Costs.PaymentStages") }); Table::OpenTable(UMyDefinition::StaticClass()); ``` ```python lib = unreal.PulseConfigTemplateLibrary lib.set_table_value(wall, "Traits:Health.MaxHealth", "650") # one undo step csv = lib.export_table_csv(walls, ["Category", "Traits:Health.MaxHealth"]) changed, problems = lib.import_table_csv(csv) lib.open_pulse_table(unreal.MyDefinition.static_class(), []) ``` ### Compare The **Compare** tab below the graph of the workshop puts the asset next to others of its class, value by value: plain values, the entries of lists that hold each type once (one row per type), other lists and single instanced values. Cells that differ from the edited asset are orange, and "Only Differences" hides the rest. ![Compare window: a wall, its variant and a foundation, the health trait opened](/PulseFoundation/images/config-compare.png) - Add assets with **Compare** in the toolbar of the workshop (the parent of a variant, its variants, the assets selected in the content browser) or **Add** in the tab (an asset you pick, all assets of the class). In the content browser, select several assets of a class and pick **Compare in Workshop**: the first one opens with the others compared. - **→** in a cell of the edited asset gives its value to all compared assets; an entry type the edited asset lacks is removed from them. **←** in a cell of another asset takes its value into the edited one. The menu of a row has **Apply to All** and **Remove from All** (removes the entry of the type, or empties a single value, in every asset, the edited one too). Each is one undo step. - The arrow in front of a row opens it: the values of every asset side by side, editable (several assets edited by hand at once). **Window** opens the comparison in a window of its own, with room for opened rows. From code (`Kit/Config/PulseConfigCompare.h`, keys as for [variants](#variants)) and scripts: ```cpp using namespace Pulse::Editor::Kit::Config; for (const Compare::FCompareRow& row : Compare::CompareAssets({ Wall, StoneWall, Fence })) { if (row.bDifferent) UE_LOG(LogTemp, Display, TEXT("%s differs"), *Compare::GetKeyLabel(UMyDefinition::StaticClass(), row.Key).ToString()); } Compare::ApplyToAll(Wall, { StoneWall, Fence }, TEXT("E:Traits:/Script/MyPlugin.MyHealthTrait")); // FEditScope, open a transaction around it OpenCompare(Wall, { StoneWall, Fence }); // the workshop with the Compare tab ``` ```python lib = unreal.PulseConfigTemplateLibrary lib.compare_in_workshop(wall, [stone_wall, fence]) lib.apply_value_to_all(wall, [stone_wall, fence], "P:RotationStep") # one undo step ``` ### Own node content A plugin can give the nodes of a struct or class (and its children) content of its own with a node provider: a chart, a sketch or bars in the body of the node (shown also while the node is closed, not in closed compact nodes), the summary, and entries of the context menu. The provider gets the asset, the path and the type of the entry; widgets read the value through `FNodeContext::GetMemory` / `GetStruct` every time, so they follow changes and undo. ```cpp class FMyProfileNode : public Pulse::Editor::Kit::Config::IConfigNodeProvider { public: virtual TSharedPtr MakeNodeContent(const Pulse::Editor::Kit::Config::FNodeContext& InContext) override { return SNew(SPulsePlot) .Series_Lambda([InContext]() { TArray series; if (const FMyProfile* profile = InContext.GetStruct()) series.Add(MakeSeries(*profile)); return series; }); } virtual FText GetSummary(const Pulse::Editor::Kit::Config::FNodeContext& InContext) override { const FMyProfile* profile = InContext.GetStruct(); return profile ? FText::Format(INVTEXT("{0} max"), profile->Max) : FText::GetEmpty(); } // Lines below the entry in the tree, and the value on the right of its row in a stack virtual void GetTreeLines(const Pulse::Editor::Kit::Config::FNodeContext& InContext, TArray& OutLines) override; virtual FText GetStackValue(const Pulse::Editor::Kit::Config::FNodeContext& InContext) override; }; // StartupModule / ShutdownModule Pulse::Editor::Kit::Config::RegisterNodeProvider(FMyProfile::StaticStruct(), MakeShared()); Pulse::Editor::Kit::Config::UnregisterNodeProvider(FMyProfile::StaticStruct()); ``` `SPulsePlot` (`Kit/SPulsePlot.h`) is the small chart of the kit for nodes, cards and side panels: series as lines, steps or bars (`FPulsePlotSeries`), a data range (or `FitRange` of the points), an optional marker line (a threshold), a legend, and the values of the nearest points under the mouse (`FormatPoint` formats them). The series are an attribute that is read on every paint. PulseBuild uses it for the integrity profiles of pieces. A series can also be a band (`EPulsePlotStyle::Band`: the area between `Points` and `LowerPoints`, filled with the color and its alpha), a dashed line (`bDashed`) or editable (`bEditable`): its points are handles, and dragging one calls `OnPointMoved` with the series, the point, where it is now in data space and, once more at the end, `bInFinished`. The owner changes its data (one undo step from the first move to the end) and the chart follows on the next paint. `DataToLocal`, `LocalToData`, `MakeDashes` and `FindHandle` are the pure parts for specs. PulseWater draws the cross-section of terrain profiles with it. ```cpp SNew(SPulsePlot) .Series_Lambda([this]() { return MakeSeries(); }) // the last series has bEditable .MarkerY(TOptional(0.0)) // the waterline .OnPointMoved_Lambda([this](int32 InSeries, int32 InPoint, const FVector2D& InPosition, bool bInFinished) { MovePoint(InPoint, InPosition, bInFinished); // Modify, set, end the undo step when finished }) ``` ## Preview viewport `SPulsePreviewViewport` (`Kit/Preview/SPulsePreviewViewport.h`) is a small 3D preview for asset editors and details panels: floor, environment and the light presets of the editor ("Preview Scene Settings") in the **View** menu, an orbit camera that frames the content when it opens, when its bounds change and when the viewport is resized (**F** frames the selection). ![Piece preview with grouped socket labels](/PulseFoundation/images/preview-viewport.png) The owner adds its components and hands out **handles** every frame, so the preview always shows the current data, also after undo: ```cpp SNew(SPulsePreviewViewport) .InfoText(this, &SMyPreview::GetInfoText) .OnGatherHandles_Lambda([this](TArray& OutHandles) { FPulsePreviewHandle& socket = OutHandles.AddDefaulted_GetRef(); socket.Id = TEXT("Socket1"); socket.Shape = EPulsePreviewHandleShape::Axes; // Point, Axes, Box, Sphere, Capsule socket.Transform = MySocketTransform; socket.Label = INVTEXT("Top (Wall)"); socket.Details = INVTEXT("Accepts Wall, Roof"); // shown when hovered or selected socket.Group = TEXT("Wall"); // filter in the View menu socket.bEditable = true; // move (W) and rotate (E) gizmo }) .OnGetFocusBounds_Lambda([this]() { return MyBounds; }) .OnHandleMoved_Lambda([this](FName InId, const FTransform& InTransform, bool bInFinished) { MyAsset->Modify(); // called inside the transaction of the drag MySocketTransform = InTransform; }); ``` Components go in with `AddComponent` (they also hide labels behind them). The bar of the viewport takes buttons of the owner in the named slot `ToolbarExtra` (before **View**). When a [viewport view](#viewport-views) supports previews, the bar also has the **Pulse** menu of the views. **Ghosts** (`FPulsePreviewHandle::bGhost`) show what an action would add: faded, with dashed axes, labels in every label mode, never selectable and grouped apart from the other handles. Hand them out like other handles while the action runs (PulseBuild: the sockets of the socket generator). **Surface picking** lets the owner place things on its content: `SetSurfacePicking(true)` sends `OnSurfaceHovered` every frame with the point and the normal under the mouse (`bHit` false next to the content) and `OnSurfaceClicked` for a left click on the content instead of selecting handles. The trace hits the components added with `AddComponent` (exact triangles, so the normal is the one of the face). ```cpp SNew(SPulsePreviewViewport) .OnSurfaceHovered_Lambda([this](const FPulsePreviewSurfaceHit& InHit) { GhostAt = InHit.bHit ? TOptional(InHit.Location) : TOptional(); }) .OnSurfaceClicked_Lambda([this](const FPulsePreviewSurfaceHit& InHit) { AddPointAt(InHit.Location, InHit.Normal); }) .ToolbarExtra() [ SNew(SButton).Text(INVTEXT("Place")).OnClicked_Lambda([this]() { Viewport->SetSurfacePicking(!Viewport->IsSurfacePicking()); return FReply::Handled(); }) ]; ``` The 3D view has the accessible name "Preview", so screen readers and UI automation (the Slate inspector of the MCP server) find it. **Labels** are laid out by the pure logic in `Kit/Preview/PulseLabelLayout.h` (specs `PulseFoundation.Editor.LabelLayout`): - points on top of each other share one label ("Top (Wall), FloorA (Floor) (+3)"), which opens into single labels when one of them is hovered or selected; - labels take one of 16 places around their point (with a leader line for the far ones) so they do not cover each other or leave the viewport, and keep their place while the camera turns; - labels behind the content are faded; - the hovered and the selected handle show their details below the label; handles with `bAlwaysShowDetails` show them all the time (readouts such as the probe of Pulse Water). Clicking a handle or its label selects it (`OnHandleSelected`); one gizmo drag is one undo step. The switch **All / Hover / Off** in the toolbar (key T) sets the texts of every Pulse preview and is saved for the user (`SPulsePreviewViewport::GetTextMode` / `SetTextMode`): Hover shows only the selected and the hovered handle, Off no texts at all (labels of handles and viewport views, legend, info text); owners hide their own captions with it. The View menu hides groups. The **gizmo** of an editable handle is the classic transform widget of the editor (the new TRS gizmos only move actors and components, so previews switch them off). It is drawn on top of the handles and stays readable: - the selected handle shows the gizmo instead of its own axes, the axes of the other handles fade towards grey; - labels keep the arrows (or the rotation circles) free: the layout gets them as `Obstacles` and moves the labels into the gaps or further out with a leader line; - while dragging, only the label of the dragged handle is shown. The preview uses a **fixed exposure** (`Pulse.Editor.PreviewEV100`, default -4.5), so the background does not pump while the camera turns. **View > Auto Exposure** (or `Pulse.Editor.PreviewAutoExposure 1`) goes back to the automatic exposure of the preview scene. `Pulse.Editor.PreviewOrbit` turns the camera of every open preview, for screenshots and tests (see [Console](/PulseFoundation/console.html)). ## Level tools `UPulseEdMode` (`Kit/LevelTools/PulseEdMode.h`) is the base of the level editor modes of Pulse plugins, like Landscape and Foliage: a mode in the mode selection of the level editor whose tools are tools of the engine's Interactive Tools Framework (`UInteractiveTool`, `UInteractiveToolBuilder`, settings as `UInteractiveToolPropertySet`). The kit adds what every Pulse mode needs, so a plugin only writes its tools: - a **tool palette** in the Pulse style: tiles with icon and name in sections, the active tool highlighted, a "NEW" mark on new tools; a click starts a tool, a click on the active tile ends it, one tool runs at a time; - the **settings** of the active tool as Pulse value rows, with its `CallInEditor` functions as buttons in their category, **Accept** and **Cancel** for tools that change something only when accepted, and the message of the tool below them (`GetToolManager()->DisplayMessage(Text, EToolMessageLevel::UserNotification)`, e.g. how to use it); - **handles with labels** in the level viewport, drawn and laid out like in the preview viewports; - a **footer** with key figures, e.g. a budget, refreshed twice a second; - a **viewport overlay**: a widget of the mode at the bottom of the level viewport, whatever tool runs, e.g. a time bar (`CreateViewportOverlay`). The kit moves it to the viewport that becomes the active one and hides it while Play in Editor runs. ```cpp // Editor module of the plugin UCLASS() class UMyEdMode : public UPulseEdMode { GENERATED_BODY() public: UMyEdMode() { // Id, name, icon, shown in the mode selection, order there Info = FEditorModeInfo(TEXT("EM_MyMode"), LOCTEXT("Name", "My Mode"), FSlateIcon(MyStyle, "MyMode.Icon"), true, 6000); } virtual void GatherTools(TArray& OutTools) const override { FPulseEdModeTool& probe = OutTools.AddDefaulted_GetRef(); probe.Id = TEXT("Probe"); // tool identifier and command name probe.Label = LOCTEXT("Probe", "Probe"); probe.Section = LOCTEXT("Water", "Water"); // heading of the tiles from here on probe.Icon = FSlateIcon(MyStyle, "MyMode.Probe"); probe.BuilderClass = UMyProbeToolBuilder::StaticClass(); probe.bNew = true; } virtual FName GetDefaultTool() const override { return TEXT("Probe"); } virtual void GatherFooter(FPulseEdModeFooter& OutFooter) const override { OutFooter.Title = LOCTEXT("Budget", "Water in the level"); FPulseEdModeMetric& bodies = OutFooter.Metrics.AddDefaulted_GetRef(); bodies.Label = LOCTEXT("Bodies", "Bodies"); bodies.Value = FText::FromString(TEXT("14/64")); bodies.Fraction = 14.0f / 64.0f; // its part of the bar } virtual TSharedPtr CreateViewportOverlay() override { return SNew(SMyTimeBar); // at the bottom of the viewport, for every tool } }; // StartupModule: the Pulse window and Pulse.Mode know the mode FPulseLevelMode mode; mode.ModeId = TEXT("EM_MyMode"); mode.PluginName = TEXT("MyPlugin"); IPulseFoundationEditor::Get().RegisterLevelMode(mode); ``` The editor finds the mode through its class; the mode selection lists it when `Info` is visible. Each tool gets a command in the binding context `PulseEdMode.`, so its key (`FPulseEdModeTool::Chord`) can be changed in the keyboard shortcuts of the editor preferences. `IPulseFoundationEditor::Get().OpenLevelMode(ModeId, ToolId)` opens a mode and starts one of its tools, e.g. from a setup step of the workflow; the console command `Pulse.Mode [tool]` does the same and lists the modes without arguments. ### Handles in the level viewport The mode hands out handles every frame with `GatherHandles` (`FPulsePreviewHandle`, as in the preview viewport), the active tool with `IPulseLevelTool::GatherHandles` (`Kit/LevelTools/PulseLevelTool.h`, an optional interface of the tool). Labels of points on top of each other share one label, labels take free places around their point and keep them, and the hovered and the selected handle show their details. A click on a handle or its label selects it (`OnHandleSelected` of the mode or the tool that owns it). Dragging an editable handle moves it on the horizontal plane, with **Shift** up and down; its owner gets `OnHandleMoved` inside the transaction of the drag, so one drag is one undo step, and **Escape** during the drag rolls it back. The handles take the mouse before the tools (gizmos come first) and only over a handle or its label, so tools get every other click; a tool that wants to leave clicks on handles to the mode asks `UPulseEdMode::FindHandleAt`. `IPulseLevelTool::GetSummary` adds a short text to the header of the tool settings ("6 points"). A tool can bring widgets of its own: `CreatePanelContent` goes below its settings in the mode panel (lists, summaries; refresh them in the tool's tick rather than inside a click of one of their own buttons, which the refresh would destroy), and `CreateViewportOverlay` takes the place of the overlay of the mode at the bottom of the viewport while the tool runs (e.g. the timeline of a simulation, see [Timeline](#timeline)); the overlay of the mode comes back when the tool ends. Both are created when the tool starts. Level viewports are often not realtime, and a covered or throttled editor ticks them only when they need to draw. The mode draws them again whenever its handles change (place, label, details, selection), and `StartTool` invalidates them, because a tool starts on the next tick of the tools context. ### Undo `Kit/LevelTools/PulseLevelToolUndo.h` makes every action of a tool one undo step, also when it spans many frames: ```cpp // A click: one step (inside another step, e.g. a running drag, it joins that one) { Pulse::Editor::Kit::LevelTools::FUndoScope undo(LOCTEXT("SetSeaLevel", "Set Sea Level"), { Ocean }); Ocean->SetSeaLevel(NewLevel); } // A drag or a brush stroke Stroke.Begin(LOCTEXT("PaintWidth", "Paint Width")); // on press Stroke.Modify(River); // before every change, recorded once per object Stroke.End(); // on release; Cancel() rolls back (Escape) // A property set like the details panel does it (PreEditChange, PostEditChangeProperty, construction scripts) Pulse::Editor::Kit::LevelTools::SetPropertyValue(Ocean, TEXT("SeaLevel"), TEXT("120")); ``` `FUndoInteraction` ends itself when it is destroyed, so a tool that shuts down in the middle of a drag leaves no open transaction. A cancelled step is rolled back like an undo and does not stay in the history. Specs: `PulseFoundation.Editor.LevelTools` (registration, undo, label groups, footer, a test mode with two tools). ## Viewport views Viewport views (`Kit/Views/PulseViewportViews.h`) show what the systems of a plugin see, right in the viewport: a water depth as a color grid, currents as arrows, network cells as lines, values as labels. They change neither materials nor the level, and cost nothing while they are off. A plugin registers its views in `StartupModule` of its editor module: ```cpp FPulseViewportView view; view.Id = TEXT("MyPlugin.Depth"); // also the argument of Pulse.Views view.DisplayName = LOCTEXT("Depth", "Depth"); view.Category = LOCTEXT("Water", "Water"); // section of the menu view.RefreshInterval = 0.25f; // seconds between two builds, 0 = every frame view.OnBuild = FOnBuildPulseView::CreateLambda([](const FPulseViewContext& InContext, FPulseViewDrawing& OutDrawing) { using namespace Pulse::Editor::Kit::Views; static const FPulseViewColorStop stops[] = { { 0.0f, FLinearColor(0.7f, 0.9f, 1.0f, 0.5f) }, { 10.0f, FLinearColor(0.0f, 0.1f, 0.6f, 0.7f) } }; for (const FVector2D& center : MakeGridAroundView(InContext, 0.0, 200.0, 64)) { FPulseViewDrawing::FCell& cell = OutDrawing.Cells.AddDefaulted_GetRef(); cell.Center = FVector(center, 5.0); cell.Size = FVector2D(200.0); cell.Color = SampleGradient(stops, MyDepthAt(InContext.World, center)); } OutDrawing.Legend.Add({ LOCTEXT("Shallow", "0 m"), stops[0].Color }); OutDrawing.Legend.Add({ LOCTEXT("Deep", "10 m"), stops[1].Color }); }); Pulse::Editor::Kit::Views::RegisterView(view); // ShutdownModule Pulse::Editor::Kit::Views::UnregisterView(TEXT("MyPlugin.Depth")); ``` - **Turning them on**: the **Pulse** menu in the toolbar of level viewports, next to the view modes (its label names the view that is on, "Pulse: Depth", or how many are on), and in the bar of preview viewports for views with `bPreviewViewports`. Several views can be on at once, and every viewport keeps its own. The console command `Pulse.Views [on|off]` switches a view in the active level viewport (`Pulse.Views` lists them, `Pulse.Views none` turns all off). - **Play in Editor**: a PIE window starts with the views of the level viewport it was started from (plus the views with `bOnInNewGameViewports`, e.g. data the user pinned; the legend lists those only while they draw something, so a window without such data stays clean) and builds them from its own world, so the server window shows the server and a client window the client (`FPulseViewContext::WorldLabel` is "Server", "Client 1", "Standalone" or "Editor"). `Pulse.Views` in the console of a PIE window switches the views of that window. - **Drawing**: cells are flat see-through squares and depth tested, so terrain in front hides them; a cell with a `Normal` is tilted into the plane of it (seen from above it still covers `Size`), so cells on waves or slopes meet without steps (`GetCellCorners` gives its corners). Lines and arrows are depth tested too, their thickness is in world units; labels are drawn on top with the layout of the preview labels; the legend (the name of each view, its colors and its note) sits in the bottom left corner. Cells of nearly the same color share one mesh. - **Building**: `OnBuild` runs on the game thread for each viewport every `RefreshInterval` seconds, with the world and the camera of that viewport; the drawing reuses the last build in between. Keep it cheap (a grid of 64 x 64 points is 4096 queries); for expensive data, build it in a task and hand out the last result. - **Helpers**: `MakeGridAroundView` gives the centers of a grid around the point the camera looks at, snapped to the cell size so the grid does not swim; `SampleGradient` the color of a value between stops; `BuildView` the drawing of a view for a world and camera without a viewport (tests). Other editors with a viewport can add the toggles to their menus with `FillViewsMenu`. Level viewports that are not realtime draw again whenever a view built a new drawing. Specs: `PulseFoundation.Editor.Views` (registration, state per viewport, menu label, `Pulse.Views`, grid, gradient, cell meshes, tilted cells, the drawing of a level viewport). ## Timeline `SPulseTransportBar` (`Kit/Timeline/SPulseTransportBar.h`) is the transport bar of the kit: rewind, play and pause, a step, the time (type over it to jump: `12.4`, `1:15.3`, `1:02:03`), a scrub slider, speed buttons and room for more at the right end. The bar plays nothing itself: the owner keeps the time, binds the attributes to it and answers the events. A mode shows it at the bottom of the level viewport with `CreateViewportOverlay`. ```cpp SNew(SPulseTransportBar) .Label(LOCTEXT("WaterTime", "Water time")) .Time_Lambda([this]() { return Time; }) .IsPlaying_Lambda([this]() { return bPlaying; }) .Speed_Lambda([this]() { return Speed; }) .Speeds({ 1.0f, 10.0f, 60.0f }) // the default .OnTimeChanged_Lambda([this](double InTime) { Time = InTime; }) .OnPlayingChanged_Lambda([this](bool bInPlaying) { bPlaying = bInPlaying; }) .OnSpeedChanged_Lambda([this](float InSpeed) { Speed = InSpeed; }) .ExtraContent() [ SNew(SMySeaStateButton) ]; ``` - **Scrubbing** moves the time within a window (`ScrubWindow`: 60 s at speed 1, longer at higher speeds) and pauses, as **Step** does (`StepTime`: 0.1 s at speed 1, longer at higher speeds). **Rewind** sets 0. - `TogglePlay`, `Rewind`, `Step`, `SetSpeed`, `EnterTime` and `Scrub` do what the buttons do, for keys and tests. - `Kit/Timeline/PulseTimeline.h` shows and reads times: `FormatTime` ("00:12.4", "1:02:03.4"), `ParseTime` (also "12,4" and "12.4 s") and `GetWindowStart` (the window of the scrub slider). `SPulseTimeline` (`Kit/Timeline/SPulseTimeline.h`) puts the transport bar above a time ruler and tracks: named rows of ranges (a simulated span, an event that lasts) and markers ("settled after 9 s"), and the playhead. A status text follows the time ("Simulated 24 s, settled after 9 s"), chips go into `ExtraContent`. Clicking or dragging on the tracks seeks (and pauses); on the loop track (`LoopTrack`) dragging sets the loop span, and its edges can be dragged. Like the bar it keeps no state: the owner gives time, tracks and loop span and answers the events. ```cpp SNew(SPulseTimeline) .Time_Lambda([this]() { return Time; }) .IsPlaying_Lambda([this]() { return bPlaying; }) .Tracks_Lambda([this]() { return MakeTracks(); }) // TArray .LoopTrack(TEXT("Loop")) .LoopSpan_Lambda([this]() { return LoopSpan; }) // TOptional .Status_Lambda([this]() { return StatusText; }) .OnTimeChanged_Lambda([this](double InTime) { Time = InTime; }) .OnPlayingChanged_Lambda([this](bool bInPlaying) { bPlaying = bInPlaying; }) .OnLoopSpanChanged_Lambda([this](FPulseTimeSpan InSpan) { LoopSpan = InSpan; }); ``` - The visible span is `ViewSpan`, or unset it fits the tracks and the time (`FitView`: from 0 to a round end). - `Seek` and `DragLoopSpan` do what the mouse does, for keys and tests. `TimeToX`, `XToTime`, `GetRulerStep` (1, 2 or 5 times a power of ten) and `MakeDragSpan` are the pure helpers behind the drawing. Specs: `PulseFoundation.Editor.Timeline` (format, typed times, windows, the events of the bar, the tracks: mapping, ruler steps, fitting, dragging, seeking and the loop span). ## Background tasks Long editor work runs on a worker thread with `Pulse::Editor::Kit::Tasks::Start` (`Kit/Tasks/PulseBackgroundTasks.h`): baking a fluid, rendering icons, checking a large project. The editor stays usable; a notification shows the progress and a **Cancel** button, the status bar the percentage. ```cpp FPulseBackgroundTask task; task.Title = LOCTEXT("Bake", "Bake Muehlbach_Flow"); task.Details = LOCTEXT("BakeDetails", "Loop 8 s, 160 frames"); task.TotalWork = 160; task.Work = [Input, Output](FPulseBackgroundTaskContext& InContext) // worker thread { for (int32 frame = 0; frame < 160 && !InContext.IsCancelled(); ++frame) { Output->Frames.Add(Simulate(*Input, frame)); InContext.ReportProgress(frame + 1); } }; task.OnFinished = [Asset, Output](bool bInCancelled) // game thread { if (bInCancelled) return; const FScopedTransaction transaction(LOCTEXT("ApplyBake", "Bake Fluid")); Asset->Modify(); Asset->Apply(*Output); // one undo step }; const int32 id = Pulse::Editor::Kit::Tasks::Start(MoveTemp(task)); ``` - The work may only use data it copied before (no UObjects the editor changes meanwhile). `OnFinished` runs on the game thread afterwards, with whether the task was cancelled; apply the result there in one transaction. - `IsRunning`, `Cancel`, `GetProgress` (0 to 1) and `Wait` (runs `OnFinished` now; tests) take the id. Closing the editor cancels and waits for every task. Unattended runs show no notifications. Specs: `PulseFoundation.Editor.Tasks` (worker and game thread, progress, cancelling). ## Icons and thumbnails Assets with an icon and a mesh (building pieces, items) get two things from `Kit/PulseIcons.h`: - **Thumbnails** in the content browser: the icon, else the mesh. - **Render Icon**: renders the icon from the mesh into a texture `T__Icon` next to the asset (or into the folder of the settings) and sets the icon property, as one undo step. Rendering again updates that texture. It is in the context menu of the assets (also for several at once) and in the toolbar of their config workshop. Register the class in the editor module of the plugin, with the names of the properties or a function for the mesh: ```cpp // Icon and WorldMesh properties (object or soft object references) FPulseIconSource source; source.IconProperty = TEXT("Icon"); source.MeshProperty = TEXT("WorldMesh"); Pulse::Editor::Kit::Icons::RegisterIconSource(TEXT("/Script/MyPlugin.MyItemDefinition"), source); // Or the mesh with transform and materials (a building piece shows tier 0) source.GetMesh = [](const UObject* InAsset, FPulseIconMesh& OutMesh) { /* fill Mesh, Transform, Materials */ return OutMesh.Mesh != nullptr; }; // ShutdownModule Pulse::Editor::Kit::Icons::UnregisterIconSource(TEXT("/Script/MyPlugin.MyItemDefinition")); ``` How icons look is set for the whole team in **Project Settings > Pulse > Foundation > Icon Renderer** (`UPulseIconSettings`, saved in `DefaultEditor.ini`): size (256), camera direction (a bit from above, the front of the asset), field of view, padding, exposure (EV100, like the previews), transparent or colored background, supersampling, folder. The renderer draws the mesh in a preview scene with the light of the editor previews, like a thumbnail (without Lumen, which needs the history of a viewport), at twice the size for smooth edges. A second pass (scene color of a capture) gives the coverage of the mesh for the alpha channel. `RenderMeshIcon` returns the pixels for own uses; `GetFramingDistance`, `CombineIconPixels` and `Downsample` are the pure parts (specs `PulseFoundation.Editor.Icons`; the rendering spec runs only with a renderer, not with `-nullrhi`). ## Style `Pulse::Editor::Kit::Style` has the rounded brushes, card colors, severity colors and icons and the fonts of the kit. Use them in plugin editors so all Pulse editors look the same.

PulseFoundation plugin documentation.

This site uses Just the Docs, a documentation theme for Jekyll.