diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
index f2090c7..ad84954 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas.cs
@@ -58,6 +58,11 @@ public sealed class ModelSystemCanvas : Control
private static readonly IBrush LinkSelBrush = Brushes.OrangeRed;
private static readonly IBrush PendingLinkBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0xCC, 0x71));
private static readonly DashStyle PendingLinkDash = new DashStyle([6, 4], 0);
+ // Ghost node styling
+ private static readonly IBrush GhostNodeFill = new SolidColorBrush(Color.FromArgb(0x50, 0x2C, 0x3E, 0x50));
+ private static readonly IBrush GhostNodeBorderBrush = new SolidColorBrush(Color.FromRgb(0x77, 0x88, 0x99));
+ private static readonly IBrush GhostNodeSelBrush = Brushes.DodgerBlue;
+ private static readonly DashStyle GhostNodeDash = new DashStyle([6, 4], 0);
// Parameter value row
private static readonly IBrush ParamValueTextBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xE0, 0x82));
@@ -330,12 +335,14 @@ private void Attach()
_vm.Starts.CollectionChanged += OnCollectionChanged;
_vm.Links.CollectionChanged += OnCollectionChanged;
_vm.CommentBlocks.CollectionChanged += OnCollectionChanged;
+ _vm.GhostNodes.CollectionChanged += OnCollectionChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
foreach (var n in _vm.Nodes) ((INotifyPropertyChanged)n).PropertyChanged += OnElementPropertyChanged;
foreach (var s in _vm.Starts) ((INotifyPropertyChanged)s).PropertyChanged += OnElementPropertyChanged;
foreach (var l in _vm.Links) ((INotifyPropertyChanged)l).PropertyChanged += OnElementPropertyChanged;
foreach (var c in _vm.CommentBlocks) ((INotifyPropertyChanged)c).PropertyChanged += OnElementPropertyChanged;
+ foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged += OnElementPropertyChanged;
}
private void Detach()
@@ -345,12 +352,14 @@ private void Detach()
_vm.Starts.CollectionChanged -= OnCollectionChanged;
_vm.Links.CollectionChanged -= OnCollectionChanged;
_vm.CommentBlocks.CollectionChanged -= OnCollectionChanged;
+ _vm.GhostNodes.CollectionChanged -= OnCollectionChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
foreach (var n in _vm.Nodes) ((INotifyPropertyChanged)n).PropertyChanged -= OnElementPropertyChanged;
foreach (var s in _vm.Starts) ((INotifyPropertyChanged)s).PropertyChanged -= OnElementPropertyChanged;
foreach (var l in _vm.Links) ((INotifyPropertyChanged)l).PropertyChanged -= OnElementPropertyChanged;
foreach (var c in _vm.CommentBlocks) ((INotifyPropertyChanged)c).PropertyChanged -= OnElementPropertyChanged;
+ foreach (var g in _vm.GhostNodes) ((INotifyPropertyChanged)g).PropertyChanged -= OnElementPropertyChanged;
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
@@ -460,6 +469,7 @@ public override void Render(DrawingContext ctx)
RenderCommentBlocks(ctx);
RenderLinks(ctx);
RenderNodes(ctx);
+ RenderGhostNodes(ctx);
RenderStarts(ctx);
RenderPendingLink(ctx);
RenderSelectionRect(ctx);
@@ -596,6 +606,13 @@ private void RenderLinks(DrawingContext ctx)
if (midXInHSpan)
borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom;
}
+ else if (link.Destination is GhostNodeViewModel ghostDestH)
+ {
+ var dRect = new Rect(ghostDestH.X, ghostDestH.Y, ghostDestH.Width, ghostDestH.Height);
+ midXInHSpan = midX >= dRect.X && midX <= dRect.Right;
+ if (midXInHSpan)
+ borderY = p1.Y <= destCenter.Y ? dRect.Y : dRect.Bottom;
+ }
if (midXInHSpan)
{
@@ -635,6 +652,13 @@ private void RenderLinks(DrawingContext ctx)
if (midYInVSpan)
borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right;
}
+ else if (link.Destination is GhostNodeViewModel ghostDestV)
+ {
+ var dRect = new Rect(ghostDestV.X, ghostDestV.Y, ghostDestV.Width, ghostDestV.Height);
+ midYInVSpan = midY >= dRect.Y && midY <= dRect.Bottom;
+ if (midYInVSpan)
+ borderX = p1.X <= destCenter.X ? dRect.X : dRect.Right;
+ }
if (midYInVSpan)
{
@@ -692,6 +716,12 @@ private void RenderLinks(DrawingContext ctx)
return ClipLineToRect(other, rect);
}
+ if (element is GhostNodeViewModel gnvm)
+ {
+ var rect = new Rect(gnvm.X, gnvm.Y, gnvm.Width, gnvm.Height);
+ return ClipLineToRect(other, rect);
+ }
+
if (element is StartViewModel)
{
var center = new Point(element.CenterX, element.CenterY);
@@ -978,12 +1008,14 @@ private double NodeRenderHeight(NodeViewModel node)
private double ElementRenderWidth(ICanvasElement el) =>
el is NodeViewModel nvm ? NodeRenderWidth(nvm)
: el is CommentBlockViewModel cvm ? cvm.Width
+ : el is GhostNodeViewModel gnvm ? gnvm.Width
: 0;
/// Returns the rendered height of any resizable canvas element.
private double ElementRenderHeight(ICanvasElement el) =>
el is NodeViewModel nvm ? NodeRenderHeight(nvm)
: el is CommentBlockViewModel cvm ? cvm.Height
+ : el is GhostNodeViewModel gnvm ? gnvm.Height
: 0;
private void RenderNodes(DrawingContext ctx)
@@ -1165,6 +1197,46 @@ private void RenderNodes(DrawingContext ctx)
/// Updates based on which node (if any) the
/// pointer currently sits over, and invalidates the visual when the value changes.
///
+ private void RenderGhostNodes(DrawingContext ctx)
+ {
+ foreach (var ghost in _vm!.GhostNodes)
+ {
+ double rw = ghost.Width;
+ double rh = ghost.Height;
+ var rect = new Rect(ghost.X, ghost.Y, rw, rh);
+
+ // Fill is semi-transparent; border is dashed.
+ var borderBrush = ghost.IsSelected ? GhostNodeSelBrush : GhostNodeBorderBrush;
+ var border = new Pen(borderBrush, NodeBorderThickness, dashStyle: GhostNodeDash);
+
+ ctx.DrawRectangle(GhostNodeFill, border, rect, NodeCornerRadius, NodeCornerRadius);
+
+ // Ghost icon prefix ("⊙ ") to distinguish from real nodes at a glance.
+ var labelText = "\u2299 " + ghost.Name;
+ var ft = MakeText(labelText, NodeFontSize, NodeTextBrush);
+ var tx = ghost.X + (rw - ft.Width) / 2;
+ var ty = ghost.Y + (NodeHeaderHeight - ft.Height) / 2;
+
+ using (ctx.PushClip(new Rect(ghost.X + 4, ghost.Y, rw - 8, NodeHeaderHeight)))
+ ctx.DrawText(ft, new Point(tx, ty));
+
+ // Resize grip dots (bottom-right corner).
+ {
+ double dotR = 2.0;
+ double bx = ghost.X + rw;
+ double by = ghost.Y + rh;
+ for (int d = 0; d < 3; d++)
+ {
+ double offset = 4.0 + d * 4.0;
+ ctx.DrawEllipse(ResizeHandleBrush, null,
+ new Point(bx - offset + dotR, by - dotR), dotR, dotR);
+ ctx.DrawEllipse(ResizeHandleBrush, null,
+ new Point(bx - dotR, by - offset + dotR), dotR, dotR);
+ }
+ }
+ }
+ }
+
private void RenderStarts(DrawingContext ctx)
{
foreach (var start in _vm!.Starts)
@@ -1455,7 +1527,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
// Always commit any open inline edit first.
if (_editingParamNode is not null) CommitParamEdit();
- if (hit is NodeViewModel or CommentBlockViewModel)
+ if (hit is NodeViewModel or CommentBlockViewModel or GhostNodeViewModel)
{
// On the very first Ctrl+click, absorb the existing primary selection into the set.
if (_multiSelection.Count == 0 && _vm.SelectedElement is not null
@@ -1568,6 +1640,8 @@ protected override void OnPointerMoved(PointerEventArgs e)
resizingNode.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
else if (_resizing is CommentBlockViewModel resizingComment)
resizingComment.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
+ else if (_resizing is GhostNodeViewModel resizingGhost)
+ resizingGhost.ResizeTo(_resizeStartW + dw, _resizeStartH + dh);
InvalidateAndMeasure();
e.Handled = true;
return;
@@ -1623,6 +1697,7 @@ protected override void OnPointerMoved(PointerEventArgs e)
if (el is NodeViewModel gnvm) gnvm.MoveTo(nx, ny);
else if (el is StartViewModel gsvm) gsvm.MoveTo(nx, ny);
else if (el is CommentBlockViewModel gcvm) gcvm.MoveTo(nx, ny);
+ else if (el is GhostNodeViewModel ggvm) ggvm.MoveTo(nx, ny);
}
}
else
@@ -1633,6 +1708,7 @@ protected override void OnPointerMoved(PointerEventArgs e)
if (_dragging is NodeViewModel nvm) nvm.MoveTo(newX, newY);
if (_dragging is StartViewModel svm) svm.MoveTo(newX, newY);
if (_dragging is CommentBlockViewModel cvm) cvm.MoveTo(newX, newY);
+ if (_dragging is GhostNodeViewModel gvm) gvm.MoveTo(newX, newY);
}
InvalidateAndMeasure();
@@ -1736,6 +1812,26 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e)
firstHit ??= comment;
}
}
+ foreach (var ghost in _vm.GhostNodes)
+ {
+ var gr = new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height);
+ if (finalRect.Intersects(gr))
+ {
+ _multiSelection.Add(ghost);
+ ghost.IsSelected = true;
+ firstHit ??= ghost;
+ }
+ }
+ foreach (var start in _vm.Starts)
+ {
+ var sr = new Rect(start.X, start.Y, start.Diameter, start.Diameter);
+ if (finalRect.Intersects(sr))
+ {
+ _multiSelection.Add(start);
+ start.IsSelected = true;
+ firstHit ??= start;
+ }
+ }
if (firstHit is not null)
_vm.SelectedElement = firstHit;
}
@@ -1898,6 +1994,49 @@ private void ShowContextMenu(ICanvasElement? element, LinkViewModel? link)
}
}
+ // ── Create Ghost Node + Move to Boundary (regular nodes) ───────────────────
+ if (element is NodeViewModel ghostSourceNode)
+ {
+ var capturedGhostSource = ghostSourceNode;
+
+ var ghostItem = new MenuItem { Header = "Create Ghost Node" };
+ ghostItem.Click += (_, _) =>
+ {
+ double rw = NodeRenderWidth(capturedGhostSource);
+ double rh = NodeRenderHeight(capturedGhostSource);
+ const double gap = 30.0;
+ int gx = (int)(capturedGhostSource.X + rw + gap);
+ int gy = (int)capturedGhostSource.Y;
+ int gw = (int)rw;
+ int gh = (int)rh;
+ vm.CreateGhostNode(capturedGhostSource, gx, gy, gw, gh);
+ };
+
+ var moveNodeItem = new MenuItem { Header = "Move to Boundary…" };
+ moveNodeItem.Click += async (_, _) =>
+ {
+ await vm.MoveNodeToBoundaryAsync(capturedGhostSource);
+ InvalidateAndMeasure();
+ };
+
+ menu.Items.Add(new Separator());
+ menu.Items.Add(ghostItem);
+ menu.Items.Add(moveNodeItem);
+ }
+
+ // ── Move to Boundary (ghost nodes) ────────────────────────────────────
+ if (element is GhostNodeViewModel capturedGhost)
+ {
+ var moveGhostItem = new MenuItem { Header = "Move to Boundary…" };
+ moveGhostItem.Click += async (_, _) =>
+ {
+ await vm.MoveGhostNodeToBoundaryAsync(capturedGhost);
+ InvalidateAndMeasure();
+ };
+ menu.Items.Add(new Separator());
+ menu.Items.Add(moveGhostItem);
+ }
+
menu.Items.Add(deleteItem);
ContextMenu = menu;
@@ -2029,6 +2168,16 @@ private void OnInlineEditorLostFocus(object? sender, Avalonia.Interactivity.Rout
if (handle.Contains(pos))
return comment;
}
+ foreach (var ghost in _vm.GhostNodes)
+ {
+ var handle = new Rect(
+ ghost.X + ghost.Width - ResizeHandleSize,
+ ghost.Y + ghost.Height - ResizeHandleSize,
+ ResizeHandleSize,
+ ResizeHandleSize);
+ if (handle.Contains(pos))
+ return ghost;
+ }
return null;
}
@@ -2149,6 +2298,13 @@ private static Rect InlineMinimizeButtonRect(NodeViewModel node)
return node;
}
+ // Ghost nodes
+ foreach (var ghost in _vm.GhostNodes)
+ {
+ if (new Rect(ghost.X, ghost.Y, ghost.Width, ghost.Height).Contains(pos))
+ return ghost;
+ }
+
// Comment blocks (background layer)
if (testComments)
{
@@ -2173,7 +2329,15 @@ private void ClearMultiSelection()
foreach (var el in _multiSelection)
el.IsSelected = false;
_multiSelection.Clear();
- _vm?.SelectedElement = null;
+ // Also clear IsSelected on the primary selected element (which may not be in
+ // _multiSelection when using single-select). This must happen before zeroing
+ // SelectedElement so callers that immediately invoke SelectElementCommand or
+ // SelectLinkCommand don't skip the IsSelected reset (those commands guard on
+ // SelectedElement being non-null, but it will already be null after this method).
+ if (_vm?.SelectedElement is { } primary)
+ primary.IsSelected = false;
+ if (_vm is not null)
+ _vm.SelectedElement = null;
}
/// Returns a that always has non-negative width and height,
diff --git a/src/XTMF2.GUI/MainWindow.axaml b/src/XTMF2.GUI/MainWindow.axaml
index 5d4b954..3acfc11 100644
--- a/src/XTMF2.GUI/MainWindow.axaml
+++ b/src/XTMF2.GUI/MainWindow.axaml
@@ -29,6 +29,11 @@
+
diff --git a/src/XTMF2.GUI/MainWindow.axaml.cs b/src/XTMF2.GUI/MainWindow.axaml.cs
index 644bb22..609cc9c 100644
--- a/src/XTMF2.GUI/MainWindow.axaml.cs
+++ b/src/XTMF2.GUI/MainWindow.axaml.cs
@@ -354,6 +354,7 @@ private void OnDocumentDockPropertyChanged(object? sender, PropertyChangedEventA
UndoCommand.NotifyCanExecuteChanged();
RedoCommand.NotifyCanExecuteChanged();
+ NavigateBoundaryCommand.NotifyCanExecuteChanged();
}
/// Refreshes undo/redo can-execute state when the active editor's state changes.
@@ -375,6 +376,10 @@ or nameof(ModelSystemEditorViewModel.CanRedo))
private void Redo() => _activeEditorVm?.RedoCommand.Execute(null);
private bool CanExecuteRedo() => _activeEditorVm?.CanRedo ?? false;
+ [RelayCommand(CanExecute = nameof(CanExecuteNavigateBoundary))]
+ private void NavigateBoundary() => _activeEditorVm?.BrowseBoundariesCommand.Execute(null);
+ private bool CanExecuteNavigateBoundary() => _activeEditorVm is not null;
+
private void Exit_Click(object? sender, RoutedEventArgs e) => Close();
private void Window_KeyDown(object? sender, KeyEventArgs e)
diff --git a/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
new file mode 100644
index 0000000..cb8648f
--- /dev/null
+++ b/src/XTMF2.GUI/ViewModels/GhostNodeViewModel.cs
@@ -0,0 +1,117 @@
+/*
+ Copyright 2026 University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using System;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using XTMF2;
+using XTMF2.Editing;
+using XTMF2.ModelSystemConstruct;
+
+namespace XTMF2.GUI.ViewModels;
+
+///
+/// Wraps a for display on the model system canvas.
+/// Ghost nodes are rendered as rectangular boxes with a dashed border and no hooks.
+/// They always mirror the name of their referenced node.
+///
+public sealed partial class GhostNodeViewModel : ObservableObject, ICanvasElement
+{
+ /// The underlying ghost node model object.
+ public GhostNode UnderlyingGhostNode { get; }
+
+ private readonly ModelSystemSession _session;
+ private readonly User _user;
+
+ // ── Coordinates read directly from the underlying model ──────────────
+ ///
+ public double X => (double)UnderlyingGhostNode.Location.X;
+
+ ///
+ public double Y => (double)UnderlyingGhostNode.Location.Y;
+
+ /// Rendered width; falls back to 120 when the model value is 0.
+ public double Width => UnderlyingGhostNode.Location.Width is 0 ? 120.0 : (double)UnderlyingGhostNode.Location.Width;
+
+ /// Rendered height; falls back to 50 when the model value is 0.
+ public double Height => UnderlyingGhostNode.Location.Height is 0 ? 50.0 : (double)UnderlyingGhostNode.Location.Height;
+
+ ///
+ public double CenterX => X + Width / 2.0;
+
+ ///
+ public double CenterY => Y + Height / 2.0;
+
+ [ObservableProperty] private string _name = string.Empty;
+ [ObservableProperty] private bool _isSelected;
+
+ public GhostNodeViewModel(GhostNode ghostNode, ModelSystemSession session, User user)
+ {
+ UnderlyingGhostNode = ghostNode;
+ _session = session;
+ _user = user;
+ _name = ghostNode.Name ?? string.Empty;
+
+ ((INotifyPropertyChanged)ghostNode).PropertyChanged += OnModelPropertyChanged;
+ }
+
+ private void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(GhostNode.Name):
+ Name = UnderlyingGhostNode.Name ?? string.Empty;
+ break;
+ case nameof(GhostNode.Location):
+ OnPropertyChanged(nameof(X));
+ OnPropertyChanged(nameof(Y));
+ OnPropertyChanged(nameof(Width));
+ OnPropertyChanged(nameof(Height));
+ OnPropertyChanged(nameof(CenterX));
+ OnPropertyChanged(nameof(CenterY));
+ break;
+ }
+ }
+
+ ///
+ /// Move the ghost node to a new canvas position, persisting via the session
+ /// (supports undo/redo).
+ ///
+ public void MoveTo(double x, double y)
+ {
+ var loc = UnderlyingGhostNode.Location;
+ var w = loc.Width is 0 ? 120f : loc.Width;
+ var h = loc.Height is 0 ? 50f : loc.Height;
+ _session.SetNodeLocation(_user, UnderlyingGhostNode,
+ new Rectangle((float)x, (float)y, w, h), out _);
+ }
+
+ ///
+ /// Resize the ghost node, persisting via the session.
+ /// Width is clamped to a minimum of 120; height to a minimum of 28.
+ ///
+ public void ResizeTo(double w, double h)
+ {
+ const float minW = 120f;
+ const float minH = 28f;
+ var loc = UnderlyingGhostNode.Location;
+ _session.SetNodeLocation(_user, UnderlyingGhostNode,
+ new Rectangle(loc.X, loc.Y, Math.Max(minW, (float)w), Math.Max(minH, (float)h)),
+ out _);
+ }
+}
diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
index 9c26f55..a06c83e 100644
--- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
@@ -123,6 +123,9 @@ public string CurrentBoundaryLabel
/// Observable wrappers around .
public ObservableCollection CommentBlocks { get; } = new();
+ /// Observable wrappers around .
+ public ObservableCollection GhostNodes { get; } = new();
+
/// Observable view-models for the model system's variable list.
public ObservableCollection ModelSystemVariables { get; } = new();
@@ -335,6 +338,9 @@ private void BuildFromBoundary(Boundary boundary)
{
foreach (var node in boundary.Modules) Nodes.Add(new NodeViewModel(node, Session, User));
foreach (var start in boundary.Starts) Starts.Add(new StartViewModel(start, Session, User));
+ // Ghost nodes must be populated before links so that ResolveElement can find
+ // GhostNodeViewModel instances when a link destination is a GhostNode.
+ foreach (var ghost in boundary.GhostNodes) GhostNodes.Add(new GhostNodeViewModel(ghost, Session, User));
foreach (var link in boundary.Links) TryAddLinkViewModel(link);
foreach (var cb in boundary.CommentBlocks) CommentBlocks.Add(new CommentBlockViewModel(cb, Session, User));
}
@@ -349,6 +355,7 @@ private void SubscribeToBoundary(Boundary boundary)
((INotifyCollectionChanged)boundary.Starts).CollectionChanged += OnStartsChanged;
((INotifyCollectionChanged)boundary.Links).CollectionChanged += OnLinksChanged;
((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged += OnCommentBlocksChanged;
+ ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged += OnGhostNodesChanged;
// Keep the same wrapper instance so we can correctly remove the handler later.
_subscribedChildBoundaries = boundary.Boundaries;
@@ -361,6 +368,7 @@ private void UnsubscribeFromBoundary(Boundary boundary)
((INotifyCollectionChanged)boundary.Starts).CollectionChanged -= OnStartsChanged;
((INotifyCollectionChanged)boundary.Links).CollectionChanged -= OnLinksChanged;
((INotifyCollectionChanged)boundary.CommentBlocks).CollectionChanged -= OnCommentBlocksChanged;
+ ((INotifyCollectionChanged)boundary.GhostNodes).CollectionChanged -= OnGhostNodesChanged;
if (_subscribedChildBoundaries is not null)
{
@@ -390,6 +398,7 @@ public void SwitchToBoundary(Boundary boundary)
Starts.Clear();
Links.Clear();
CommentBlocks.Clear();
+ GhostNodes.Clear();
_currentBoundary = boundary;
OnPropertyChanged(nameof(CurrentBoundary));
@@ -492,6 +501,20 @@ private void OnCommentBlocksChanged(object? sender, NotifyCollectionChangedEvent
}
}
+ private void OnGhostNodesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.NewItems is not null)
+ foreach (GhostNode g in e.NewItems)
+ GhostNodes.Add(new GhostNodeViewModel(g, Session, User));
+
+ if (e.OldItems is not null)
+ foreach (GhostNode g in e.OldItems)
+ {
+ var vm = GhostNodes.FirstOrDefault(v => v.UnderlyingGhostNode == g);
+ if (vm is not null) GhostNodes.Remove(vm);
+ }
+ }
+
private void TryAddLinkViewModel(Link link)
{
var originElement = ResolveElement(link.Origin);
@@ -544,9 +567,14 @@ private void OnMultiLinkDestinationsChanged(
private ICanvasElement? ResolveElement(Node? node)
{
if (node is null) return null;
+ if (node is GhostNode ghost)
+ return GhostNodes.FirstOrDefault(g => g.UnderlyingGhostNode == ghost);
if (node is Start start)
return Starts.FirstOrDefault(s => s.UnderlyingStart == start);
- return Nodes.FirstOrDefault(n => n.UnderlyingNode == node);
+ var directVm = Nodes.FirstOrDefault(n => n.UnderlyingNode == node);
+ if (directVm is not null) return directVm;
+ // The real node lives in another boundary — use a ghost referencing it if one is visible here.
+ return GhostNodes.FirstOrDefault(g => g.UnderlyingGhostNode.ReferencedNode == node);
}
// ── Commands ──────────────────────────────────────────────────────────
@@ -742,7 +770,7 @@ private static (Type Context, Type Return)? GetIFunction2Types(Type? nodeType)
}
///
- /// Creates a new node whose generic parameter
+ /// Creates a new node whose generic parameter
/// matches the return type T that 's module implements
/// via IFunction<T>. The new node is positioned to the right of
/// and its Context hook is linked back to the source.
@@ -754,7 +782,7 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode)
var returnType = GetIFunctionReturnType(sourceNode.UnderlyingNode.Type);
if (returnType is null) return;
- var executeWithContextType = typeof(Execute<>).MakeGenericType(returnType);
+ var executeWithContextType = typeof(ExecuteWithContext<>).MakeGenericType(returnType);
var nameDialog = new InputDialog(
title: $"Add Execute With Context<{returnType.Name}> Module",
@@ -778,7 +806,7 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode)
}
// Find the "Context" hook on the new ExecuteWithContext node and link it to sourceNode.
- var contextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Context");
+ var contextHook = newNode!.Hooks.FirstOrDefault(h => h.Name == "Get Context");
if (contextHook is not null)
{
if (!Session.AddLink(User, newNode, contextHook, sourceNode.UnderlyingNode, out _, out var linkError))
@@ -786,6 +814,48 @@ public async Task CreateExecuteWithContextAsync(NodeViewModel sourceNode)
}
}
+ ///
+ /// Creates a ghost node referencing on the current boundary,
+ /// placed at the specified canvas coordinates.
+ /// Called directly by the canvas (not a RelayCommand because it requires typed parameters).
+ ///
+ internal void CreateGhostNode(NodeViewModel nvm, int x, int y, int w, int h)
+ {
+ var location = new Rectangle(x, y, w, h);
+ if (!Session.AddGhostNode(User, _currentBoundary, nvm.UnderlyingNode, location,
+ out _, out var error))
+ ShowToast(error?.Message ?? "Failed to create ghost node.", isError: true, durationMs: 4000);
+ }
+
+ ///
+ /// Shows a boundary picker and moves the given regular node (and its outgoing links) to the
+ /// chosen boundary.
+ ///
+ internal async Task MoveNodeToBoundaryAsync(NodeViewModel nvm)
+ {
+ if (ParentWindow is null) return;
+ var dialog = new Views.BoundaryPickerDialog(GetAllBoundaries(GlobalBoundary), _currentBoundary);
+ await dialog.ShowDialog(ParentWindow);
+ if (dialog.Result != Views.BoundaryPickerResult.Navigate || dialog.SelectedBoundary is null) return;
+ if (ReferenceEquals(dialog.SelectedBoundary, nvm.UnderlyingNode.ContainedWithin)) return;
+ if (!Session.MoveNodeToBoundary(User, nvm.UnderlyingNode, dialog.SelectedBoundary, out var error))
+ ShowToast(error?.Message ?? "Failed to move node.", isError: true, durationMs: 4000);
+ }
+
+ ///
+ /// Shows a boundary picker and moves the given ghost node reference to the chosen boundary.
+ ///
+ internal async Task MoveGhostNodeToBoundaryAsync(GhostNodeViewModel gvm)
+ {
+ if (ParentWindow is null) return;
+ var dialog = new Views.BoundaryPickerDialog(GetAllBoundaries(GlobalBoundary), _currentBoundary);
+ await dialog.ShowDialog(ParentWindow);
+ if (dialog.Result != Views.BoundaryPickerResult.Navigate || dialog.SelectedBoundary is null) return;
+ if (ReferenceEquals(dialog.SelectedBoundary, gvm.UnderlyingGhostNode.ContainedWithin)) return;
+ if (!Session.MoveGhostNodeToBoundary(User, gvm.UnderlyingGhostNode, dialog.SelectedBoundary, out var error))
+ ShowToast(error?.Message ?? "Failed to move ghost node.", isError: true, durationMs: 4000);
+ }
+
///
/// Attempt to create a link from to .
/// If any compatible hooks are found, either uses the sole hook automatically or
diff --git a/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml b/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml
index abbebbb..3adf293 100644
--- a/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml
+++ b/src/XTMF2.GUI/Views/BoundaryPickerDialog.axaml
@@ -12,24 +12,31 @@
MinHeight="300"
WindowStartupLocation="CenterOwner">
-
+
+ Margin="0,0,0,8"/>
+
+
+
-
+ SelectionMode="Single"
+ DoubleTapped="BoundaryListBox_DoubleTapped">
-
- _allBrowseItems = new();
// ── Result ────────────────────────────────────────────────────────────
/// How the dialog was closed.
@@ -108,19 +110,68 @@ public BoundaryPickerDialog(
preSelect = item;
}
+ _allBrowseItems = new List(BrowseItems);
+
// Pre-select the current boundary once the window is open and the list is rendered.
- if (preSelect is not null)
+ Opened += (_, _) =>
{
- Opened += (_, _) =>
+ if (preSelect is not null)
{
SelectedBrowseItem = preSelect;
BoundaryListBox.ScrollIntoView(preSelect);
- };
+ }
+ SearchBox.Focus();
+ };
+
+ Activated += (_, _) => SearchBox.Focus();
+
+ SearchBox.TextChanged += SearchBox_TextChanged;
+ SearchBox.KeyDown += SearchBox_KeyDown;
+ }
+
+ // ── Search ────────────────────────────────────────────────────────────
+
+ private void SearchBox_TextChanged(object? sender, TextChangedEventArgs e)
+ {
+ var query = SearchBox.Text?.Trim() ?? string.Empty;
+ BrowseItems.Clear();
+ foreach (var item in _allBrowseItems)
+ {
+ if (string.IsNullOrEmpty(query) ||
+ item.Boundary.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
+ BrowseItems.Add(item);
+ }
+ }
+ // Keep first visible item selected so the user can immediately press Enter.
+ if (BrowseItems.Count > 0 && (SelectedBrowseItem is null || !BrowseItems.Contains(SelectedBrowseItem)))
+ SelectedBrowseItem = BrowseItems[0];
+ else if (BrowseItems.Count == 0)
+ SelectedBrowseItem = null;
+ }
+
+ private void SearchBox_KeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && BrowseItems.Count > 0)
+ {
+ SelectedBrowseItem = BrowseItems[0];
+ Result = BoundaryPickerResult.Navigate;
+ SelectedBoundary = SelectedBrowseItem.Boundary;
+ Close();
+ e.Handled = true;
}
}
// ── Button handlers ───────────────────────────────────────────────────
+ private void BoundaryListBox_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
+ {
+ if (SelectedBrowseItem is null) return;
+ Result = BoundaryPickerResult.Navigate;
+ SelectedBoundary = SelectedBrowseItem.Boundary;
+ Close();
+ }
+
private void Navigate_Click(object? sender, RoutedEventArgs e)
{
if (SelectedBrowseItem is null) return;
diff --git a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
index c082841..68b2602 100644
--- a/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
+++ b/src/XTMF2.GUI/Views/ModelSystemEditorView.axaml
@@ -15,6 +15,7 @@
+
@@ -39,7 +40,7 @@
Width="200"
VerticalAlignment="Center"
Margin="0,0,8,0"
- ToolTip.Tip="Navigate to a boundary">
+ ToolTip.Tip="Navigate to a boundary (Ctrl+B)">
diff --git a/src/XTMF2/Configuration/SystemConfiguration.cs b/src/XTMF2/Configuration/SystemConfiguration.cs
index 586e271..9671d0c 100644
--- a/src/XTMF2/Configuration/SystemConfiguration.cs
+++ b/src/XTMF2/Configuration/SystemConfiguration.cs
@@ -114,11 +114,12 @@ public void LoadAssemblies(string path, string? exclusionFile = null)
public void LoadAssemblies(string path, List toExclude)
{
var dirInfo = new DirectoryInfo(path);
- foreach (var dllFile in dirInfo.EnumerateFiles("*.dll"))
+ var toLoad = dirInfo.EnumerateFiles("*.dll").Where(file => !toExclude.Contains(file.Name)).ToList();
+ foreach (var dllFile in toLoad)
{
if (!toExclude.Contains(dllFile.Name))
{
- LoadAssembly(AssemblyLoadContext.Default.LoadFromAssemblyPath(dllFile.FullName));
+ LoadAssembly(Assembly.LoadFrom(dllFile.FullName));
}
}
}
diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs
index 0fa5449..628e72c 100644
--- a/src/XTMF2/Editing/ModelSystemSession.cs
+++ b/src/XTMF2/Editing/ModelSystemSession.cs
@@ -796,6 +796,252 @@ void Remove()
/// The node to be removed.
/// An error message if the operation fails.
/// True if the operation succeeds, false otherwise with an error message.
+ ///
+ /// Add a ghost node — a visual alias for —
+ /// to at .
+ /// Ghost nodes mirror their referenced node's name, have no hooks, and are
+ /// rendered with a dashed outline. When the real node is deleted all ghost
+ /// nodes referencing it are automatically deleted as well.
+ ///
+ public bool AddGhostNode(User user, Boundary boundary, Node referencedNode, Rectangle location,
+ [NotNullWhen(true)] out GhostNode? ghostNode,
+ [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(boundary);
+ ArgumentNullException.ThrowIfNull(referencedNode);
+
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ ghostNode = null;
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+
+ var ghost = new GhostNode(referencedNode, boundary, location);
+ if (!boundary.AddGhostNode(ghost, out error))
+ {
+ ghostNode = null;
+ return false;
+ }
+ ghostNode = ghost;
+
+ Buffer.AddUndo(new Command(() =>
+ {
+ return (boundary.RemoveGhostNode(ghost, out var e), e);
+ }, () =>
+ {
+ return (boundary.AddGhostNode(ghost, out var e), e);
+ }));
+ return true;
+ }
+ }
+
+ ///
+ /// Remove a ghost node from its boundary, also removing any incoming links.
+ ///
+ public bool RemoveGhostNode(User user, GhostNode ghostNode,
+ [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(ghostNode);
+
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+
+ var boundary = ghostNode.ContainedWithin!;
+ var incomingLinks = GetLinksGoingTo(ghostNode);
+ var multiLinkInfo = BuildMultiLinkRestoreInfo(incomingLinks, ghostNode);
+
+ RemoveIncomingLinks(incomingLinks, ghostNode, multiLinkInfo);
+
+ if (boundary.RemoveGhostNode(ghostNode, out error))
+ {
+ Buffer.AddUndo(new Command(() =>
+ {
+ if (boundary.AddGhostNode(ghostNode, out var e))
+ {
+ RestoreIncomingLinks(incomingLinks, ghostNode, multiLinkInfo);
+ return (true, null);
+ }
+ return (false, e);
+ }, () =>
+ {
+ RemoveIncomingLinks(incomingLinks, ghostNode, multiLinkInfo);
+ return (boundary.RemoveGhostNode(ghostNode, out var e), e);
+ }));
+ return true;
+ }
+ else
+ {
+ RestoreIncomingLinks(incomingLinks, ghostNode, multiLinkInfo);
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Moves a regular node (and all of its outgoing links) from its current boundary to
+ /// . The operation is undoable.
+ ///
+ public bool MoveNodeToBoundary(User user, Node node, Boundary targetBoundary,
+ [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(node);
+ ArgumentNullException.ThrowIfNull(targetBoundary);
+
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+
+ var oldBoundary = node.ContainedWithin!;
+ if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; }
+
+ // Outgoing links live in the origin node's boundary and must follow the node.
+ var outgoingLinks = oldBoundary.Links.Where(l => l.Origin == node).ToList();
+
+ // Collect hidden nodes: destination nodes of outgoing links whose location is
+ // Rectangle.Hidden (i.e. inlined parameter nodes). These must travel with the node.
+ var hiddenNodes = outgoingLinks
+ .SelectMany(l => l is SingleLink sl
+ ? (sl.Destination is not null ? new[] { sl.Destination } : Array.Empty())
+ : (l is MultiLink ml ? ml.Destinations.ToArray() : Array.Empty()))
+ .Where(n => n.Location.Equals(Rectangle.Hidden) && ReferenceEquals(n.ContainedWithin, oldBoundary))
+ .Distinct()
+ .ToList();
+
+ if (!oldBoundary.RemoveNode(node, out error)) return false;
+
+ foreach (var link in outgoingLinks)
+ oldBoundary.RemoveLink(link, out _);
+
+ foreach (var hidden in hiddenNodes)
+ oldBoundary.RemoveNode(hidden, out _);
+
+ node.UpdateContainedWithin(targetBoundary);
+ foreach (var hidden in hiddenNodes)
+ hidden.UpdateContainedWithin(targetBoundary);
+
+ if (!targetBoundary.AddNode(node, out error))
+ {
+ // Roll back hidden nodes and links before returning.
+ foreach (var hidden in hiddenNodes)
+ {
+ hidden.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddNode(hidden, out _);
+ }
+ node.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddNode(node, out _);
+ foreach (var link in outgoingLinks)
+ oldBoundary.AddLink(link, out _);
+ return false;
+ }
+
+ foreach (var hidden in hiddenNodes)
+ targetBoundary.AddNode(hidden, out _);
+
+ foreach (var link in outgoingLinks)
+ targetBoundary.AddLink(link, out _);
+
+ Buffer.AddUndo(new Command(() =>
+ {
+ foreach (var link in outgoingLinks) targetBoundary.RemoveLink(link, out _);
+ foreach (var hidden in hiddenNodes)
+ {
+ targetBoundary.RemoveNode(hidden, out _);
+ hidden.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddNode(hidden, out _);
+ }
+ targetBoundary.RemoveNode(node, out _);
+ node.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddNode(node, out _);
+ foreach (var link in outgoingLinks) oldBoundary.AddLink(link, out _);
+ return (true, null);
+ }, () =>
+ {
+ foreach (var link in outgoingLinks) oldBoundary.RemoveLink(link, out _);
+ foreach (var hidden in hiddenNodes)
+ {
+ oldBoundary.RemoveNode(hidden, out _);
+ hidden.UpdateContainedWithin(targetBoundary);
+ }
+ oldBoundary.RemoveNode(node, out _);
+ node.UpdateContainedWithin(targetBoundary);
+ targetBoundary.AddNode(node, out _);
+ foreach (var hidden in hiddenNodes) targetBoundary.AddNode(hidden, out _);
+ foreach (var link in outgoingLinks) targetBoundary.AddLink(link, out _);
+ return (true, null);
+ }));
+
+ error = null;
+ return true;
+ }
+ }
+
+ ///
+ /// Moves a ghost node from its current boundary to .
+ /// The operation is undoable.
+ ///
+ public bool MoveGhostNodeToBoundary(User user, GhostNode ghostNode, Boundary targetBoundary,
+ [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(ghostNode);
+ ArgumentNullException.ThrowIfNull(targetBoundary);
+
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+
+ var oldBoundary = ghostNode.ContainedWithin!;
+ if (ReferenceEquals(oldBoundary, targetBoundary)) { error = null; return true; }
+
+ if (!oldBoundary.RemoveGhostNode(ghostNode, out error)) return false;
+
+ ghostNode.UpdateContainedWithin(targetBoundary);
+
+ if (!targetBoundary.AddGhostNode(ghostNode, out error))
+ {
+ ghostNode.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddGhostNode(ghostNode, out _);
+ return false;
+ }
+
+ Buffer.AddUndo(new Command(() =>
+ {
+ targetBoundary.RemoveGhostNode(ghostNode, out _);
+ ghostNode.UpdateContainedWithin(oldBoundary);
+ oldBoundary.AddGhostNode(ghostNode, out _);
+ return (true, null);
+ }, () =>
+ {
+ oldBoundary.RemoveGhostNode(ghostNode, out _);
+ ghostNode.UpdateContainedWithin(targetBoundary);
+ targetBoundary.AddGhostNode(ghostNode, out _);
+ return (true, null);
+ }));
+
+ error = null;
+ return true;
+ }
+ }
+
public bool RemoveNode(User user, Node node, [NotNullWhen(false)] out CommandError? error)
{
ArgumentNullException.ThrowIfNull(user);
@@ -818,73 +1064,52 @@ public bool RemoveNode(User user, Node node, [NotNullWhen(false)] out CommandErr
// For multi-links in the incoming set, record the exact destination indices
// that refer to 'node' so they can be faithfully restored on undo.
- var multiLinkRestoreInfo = new Dictionary>();
- foreach (var link in incomingLinks)
- {
- if (link is MultiLink ml)
+ var multiLinkRestoreInfo = BuildMultiLinkRestoreInfo(incomingLinks, node);
+
+ // Cascade: collect all ghost nodes referencing this node, plus their links.
+ var ghostsOfNode = GetAllGhostNodesOf(node);
+ // Per ghost: (ghost, incomingLinksToGhost, multiLinkRestoreInfo for ghost)
+ var ghostCascadeData = ghostsOfNode
+ .Select(g =>
{
- var list = new List<(int Index, Node Dest)>();
- var dests = ml.Destinations;
- for (int i = 0; i < dests.Count; i++)
- {
- if (dests[i] == node)
- list.Add((i, dests[i]));
- }
- multiLinkRestoreInfo[ml] = list;
- }
- }
+ var gLinks = GetLinksGoingTo(g);
+ return (Ghost: g, Links: gLinks, MultiInfo: BuildMultiLinkRestoreInfo(gLinks, g));
+ })
+ .ToList();
// Remove all incoming links (or just the relevant destination entries).
void RemoveIncoming()
{
- foreach (var link in incomingLinks)
- {
- if (link is SingleLink)
- {
- link.Origin!.ContainedWithin!.RemoveLink(link, out _);
- }
- else if (link is MultiLink ml)
- {
- // Remove back-to-front so indices remain valid during removal.
- var list = multiLinkRestoreInfo[ml];
- for (int i = list.Count - 1; i >= 0; i--)
- ml.RemoveDestination(list[i].Index);
-
- // If the multi-link is now empty, remove the link object itself.
- if (ml.Destinations.Count == 0)
- ml.Origin!.ContainedWithin!.RemoveLink(ml, out _);
- }
- }
+ RemoveIncomingLinks(incomingLinks, node, multiLinkRestoreInfo);
}
// Restore all incoming links (inverse of RemoveIncoming).
void RestoreIncoming()
{
- foreach (var link in incomingLinks)
+ RestoreIncomingLinks(incomingLinks, node, multiLinkRestoreInfo);
+ }
+
+ void RemoveGhostCascade()
+ {
+ foreach (var (ghost, gLinks, gMultiInfo) in ghostCascadeData)
{
- if (link is SingleLink)
- {
- link.Origin!.ContainedWithin!.AddLink(link, out _);
- }
- else if (link is MultiLink ml)
- {
- // If the link object was fully removed, re-add it first.
- if (!ml.Origin!.ContainedWithin!.Links.Contains(ml))
- ml.Origin.ContainedWithin.AddLink(ml, out _);
+ RemoveIncomingLinks(gLinks, ghost, gMultiInfo);
+ ghost.ContainedWithin!.RemoveGhostNode(ghost, out _);
+ }
+ }
- // Re-insert destination entries in original order (front-to-back).
- var list = multiLinkRestoreInfo[ml];
- for (int i = 0; i < list.Count; i++)
- {
- if(!ml.AddDestination(list[i].Dest, list[i].Index, out var e))
- return;
- }
- }
+ void RestoreGhostCascade()
+ {
+ foreach (var (ghost, gLinks, gMultiInfo) in ghostCascadeData)
+ {
+ ghost.ContainedWithin!.AddGhostNode(ghost, out _);
+ RestoreIncomingLinks(gLinks, ghost, gMultiInfo);
}
}
- // Remove incoming links, then outgoing links, then the node itself.
+ // Remove incoming links, then ghost cascade, then outgoing links, then the node itself.
RemoveIncoming();
+ RemoveGhostCascade();
foreach (var link in outgoingLinks)
boundary.RemoveLink(link, out _);
@@ -897,12 +1122,13 @@ void RestoreIncoming()
{
Buffer.AddUndo(new Command(() =>
{
- // Undo: restore node first, then its outgoing links, then all incoming links.
+ // Undo: restore node first, then its outgoing links, then all incoming links, then ghosts.
if (boundary.AddNode(node, out var e))
{
foreach (var link in outgoingLinks)
boundary.AddLink(link, out e);
RestoreIncoming();
+ RestoreGhostCascade();
if (variableIndex >= 0)
{
var restoreIdx = Math.Min(variableIndex, ModelSystem.Variables.Count);
@@ -915,6 +1141,7 @@ void RestoreIncoming()
{
// Redo: same sequence as the original removal.
RemoveIncoming();
+ RemoveGhostCascade();
foreach (var link in outgoingLinks)
boundary.RemoveLink(link, out _);
ModelSystem.Variables.Remove(node);
@@ -924,9 +1151,10 @@ void RestoreIncoming()
}
else
{
- // Node removal failed; roll back the link removals (and variable removal).
+ // Node removal failed; roll back the link removals and ghost cascade.
foreach (var link in outgoingLinks)
boundary.AddLink(link, out _);
+ RestoreGhostCascade();
RestoreIncoming();
if (variableIndex >= 0)
{
@@ -1117,6 +1345,108 @@ private List GetLinksGoingTo(Node destNode)
return ret;
}
+ ///
+ /// Builds a restore-info dictionary for MultiLink entries that point to
+ /// in .
+ ///
+ private static Dictionary> BuildMultiLinkRestoreInfo(
+ List incomingLinks, Node destNode)
+ {
+ var info = new Dictionary>();
+ foreach (var link in incomingLinks)
+ {
+ if (link is MultiLink ml)
+ {
+ var list = new List<(int Index, Node Dest)>();
+ var dests = ml.Destinations;
+ for (int i = 0; i < dests.Count; i++)
+ {
+ if (dests[i] == destNode)
+ list.Add((i, dests[i]));
+ }
+ info[ml] = list;
+ }
+ }
+ return info;
+ }
+
+ ///
+ /// Removes incoming links that target from their
+ /// respective boundaries, using pre-computed multi-link restore info.
+ ///
+ private static void RemoveIncomingLinks(
+ List incomingLinks, Node destNode,
+ Dictionary> multiLinkInfo)
+ {
+ foreach (var link in incomingLinks)
+ {
+ if (link is SingleLink)
+ {
+ link.Origin!.ContainedWithin!.RemoveLink(link, out _);
+ }
+ else if (link is MultiLink ml && multiLinkInfo.TryGetValue(ml, out var list))
+ {
+ // Remove back-to-front so indices remain valid.
+ for (int i = list.Count - 1; i >= 0; i--)
+ ml.RemoveDestination(list[i].Index);
+
+ if (ml.Destinations.Count == 0)
+ ml.Origin!.ContainedWithin!.RemoveLink(ml, out _);
+ }
+ }
+ }
+
+ ///
+ /// Restores incoming links that were previously removed by
+ /// .
+ ///
+ private static void RestoreIncomingLinks(
+ List incomingLinks, Node destNode,
+ Dictionary> multiLinkInfo)
+ {
+ foreach (var link in incomingLinks)
+ {
+ if (link is SingleLink)
+ {
+ link.Origin!.ContainedWithin!.AddLink(link, out _);
+ }
+ else if (link is MultiLink ml && multiLinkInfo.TryGetValue(ml, out var list))
+ {
+ // Re-add the link object if it was fully removed.
+ if (!ml.Origin!.ContainedWithin!.Links.Contains(ml))
+ ml.Origin.ContainedWithin.AddLink(ml, out _);
+
+ // Re-insert destinations in original order.
+ for (int i = 0; i < list.Count; i++)
+ {
+ if (!ml.AddDestination(list[i].Dest, list[i].Index, out _))
+ return;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Returns every anywhere in the model system that
+ /// references .
+ ///
+ private List GetAllGhostNodesOf(Node realNode)
+ {
+ var result = new List();
+ var stack = new Stack();
+ stack.Push(ModelSystem.GlobalBoundary);
+ while (stack.Count > 0)
+ {
+ var current = stack.Pop();
+ foreach (var child in current.Boundaries)
+ stack.Push(child);
+ foreach (var ghost in current.GhostNodes)
+ if (ghost.ReferencedNode == realNode)
+ result.Add(ghost);
+ }
+ return result;
+ }
+
///
/// Set the value of a parameter
///
diff --git a/src/XTMF2/ModelSystemConstruct/Boundary.cs b/src/XTMF2/ModelSystemConstruct/Boundary.cs
index bb7cdf3..73c1f9c 100644
--- a/src/XTMF2/ModelSystemConstruct/Boundary.cs
+++ b/src/XTMF2/ModelSystemConstruct/Boundary.cs
@@ -52,6 +52,7 @@ public sealed class Boundary : INotifyPropertyChanged
private const string LinksProperty = "Links";
private const string CommentBlocksProperty = "CommentBlocks";
private const string FunctionTemplateProperty = "FunctionTemplates";
+ private const string GhostNodesProperty = "GhostNodes";
///
/// This lock must be obtained before changing any local settings.
@@ -63,12 +64,20 @@ public sealed class Boundary : INotifyPropertyChanged
private readonly ObservableCollection _links = new ObservableCollection();
private readonly ObservableCollection _commentBlocks = new ObservableCollection();
private readonly ObservableCollection _functionTemplates = new ObservableCollection();
+ private readonly ObservableCollection _ghostNodes = new ObservableCollection();
///
/// Get readonly access to the links contained in this boundary.
///
public ReadOnlyObservableCollection Links => new ReadOnlyObservableCollection(_links);
+ ///
+ /// Get readonly access to the ghost nodes contained in this boundary.
+ /// Ghost nodes are visual aliases that point to real nodes which may reside
+ /// on a different boundary.
+ ///
+ public ReadOnlyObservableCollection GhostNodes => new ReadOnlyObservableCollection(_ghostNodes);
+
///
/// Create a new boundary, optionally with a parent
///
@@ -611,6 +620,15 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona
functionTemplate.Save(ref index, nodeDictionary, typeDictionary, writer);
}
writer.WriteEndArray();
+ // Ghost nodes are written last so that all referenced nodes already have
+ // indices in nodeDictionary (pre-assigned by PreAssignNodeIndices).
+ writer.WritePropertyName(GhostNodesProperty);
+ writer.WriteStartArray();
+ foreach (var ghost in _ghostNodes)
+ {
+ ghost.SaveObject(nodeDictionary, writer);
+ }
+ writer.WriteEndArray();
writer.WriteEndObject();
}
}
@@ -626,6 +644,63 @@ internal bool RemoveNode(Node node, [NotNullWhen(false)]out CommandError? error)
return true;
}
+ ///
+ /// Add a ghost node to this boundary.
+ ///
+ internal bool AddGhostNode(GhostNode ghostNode, [NotNullWhen(false)] out CommandError? error)
+ {
+ if (ghostNode is null) throw new ArgumentNullException(nameof(ghostNode));
+ lock (_writeLock)
+ {
+ if (_ghostNodes.Contains(ghostNode))
+ {
+ error = new CommandError("The ghost node already exists in the boundary!");
+ return false;
+ }
+ _ghostNodes.Add(ghostNode);
+ }
+ error = null;
+ return true;
+ }
+
+ ///
+ /// Remove a ghost node from this boundary.
+ ///
+ internal bool RemoveGhostNode(GhostNode ghostNode, [NotNullWhen(false)] out CommandError? error)
+ {
+ if (ghostNode is null) throw new ArgumentNullException(nameof(ghostNode));
+ lock (_writeLock)
+ {
+ if (!_ghostNodes.Remove(ghostNode))
+ {
+ error = new CommandError("Unable to find the ghost node to remove from the boundary!");
+ return false;
+ }
+ }
+ error = null;
+ return true;
+ }
+
+ ///
+ /// Pre-assigns sequential indices for all nodes (starts, modules, function template
+ /// internals) and ghost nodes in this boundary and all descendant boundaries.
+ /// This allows to be called after all indices are known,
+ /// making it possible to write links whose destinations include ghost nodes.
+ ///
+ internal void PreAssignNodeIndices(ref int index, Dictionary nodeDictionary)
+ {
+ foreach (var start in _starts)
+ if (!nodeDictionary.ContainsKey(start)) nodeDictionary[start] = index++;
+ foreach (var module in _modules)
+ if (!nodeDictionary.ContainsKey(module)) nodeDictionary[module] = index++;
+ foreach (var child in _boundaries)
+ child.PreAssignNodeIndices(ref index, nodeDictionary);
+ foreach (var ft in _functionTemplates)
+ ft.InternalModules.PreAssignNodeIndices(ref index, nodeDictionary);
+ foreach (var ghost in _ghostNodes)
+ if (!nodeDictionary.ContainsKey(ghost)) nodeDictionary[ghost] = index++;
+ }
+
///
/// This invocation should only occur with a link that was generated
/// by this boundary previously!
@@ -657,6 +732,7 @@ internal bool AddLink(Link link, [NotNullWhen(false)] out CommandError? e)
internal bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters,
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes,
ref Utf8JsonReader reader, [NotNullWhen(false)] ref string? error)
{
if (reader.TokenType != JsonTokenType.StartObject)
@@ -735,7 +811,7 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D
if (reader.TokenType != JsonTokenType.Comment)
{
var boundary = new Boundary(this);
- if (!boundary.Load(modules, typeLookup, node, scriptedParameters, ref reader, ref error))
+ if (!boundary.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error))
{
return false;
}
@@ -789,7 +865,7 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D
{
if(reader.TokenType != JsonTokenType.Comment)
{
- if(!FunctionTemplate.Load(modules, typeLookup, node, scriptedParameters, ref reader, this, out var template, ref error))
+ if(!FunctionTemplate.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, this, out var template, ref error))
{
return false;
}
@@ -797,6 +873,24 @@ internal bool Load(ModuleRepository modules, Dictionary typeLookup, D
}
}
}
+ else if (reader.ValueTextEquals(GhostNodesProperty))
+ {
+ if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
+ {
+ return Helper.FailWith(out error, "Unexpected token when starting to read Ghost Nodes for a boundary.");
+ }
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType != JsonTokenType.Comment)
+ {
+ // Defer resolution until all nodes across all boundaries are loaded.
+ if (!GhostNode.LoadDeferred(ref reader, this, deferredGhostNodes, ref error))
+ {
+ return false;
+ }
+ }
+ }
+ }
else
{
return Helper.FailWith(out error, $"Unexpected value when reading boundary {reader.GetString()}");
diff --git a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs
index 84efe2b..70b3374 100644
--- a/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs
+++ b/src/XTMF2/ModelSystemConstruct/FunctionTemplate.cs
@@ -101,6 +101,14 @@ internal void Save(ref int index, Dictionary nodeDictionary, Dictiona
/// True if the operation succeeded, false otherwise with an error message.
internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters,
ref Utf8JsonReader reader, Boundary parent, [NotNullWhen(true)] out FunctionTemplate? template, [NotNullWhen(false)] ref string? error)
+ {
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes = new();
+ return Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, parent, out template, ref error);
+ }
+
+ internal static bool Load(ModuleRepository modules, Dictionary typeLookup, Dictionary node, List<(Node toAssignTo, string parameterExpression)> scriptedParameters,
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes,
+ ref Utf8JsonReader reader, Boundary parent, [NotNullWhen(true)] out FunctionTemplate? template, [NotNullWhen(false)] ref string? error)
{
template = null;
string? name = null;
@@ -123,7 +131,7 @@ internal static bool Load(ModuleRepository modules, Dictionary typeLo
else if(reader.ValueTextEquals(nameof(InternalModules)))
{
reader.Read();
- if(!innerModules.Load(modules, typeLookup, node, scriptedParameters, ref reader, ref error))
+ if(!innerModules.Load(modules, typeLookup, node, scriptedParameters, deferredGhostNodes, ref reader, ref error))
{
return false;
}
diff --git a/src/XTMF2/ModelSystemConstruct/GhostNode.cs b/src/XTMF2/ModelSystemConstruct/GhostNode.cs
new file mode 100644
index 0000000..acb7203
--- /dev/null
+++ b/src/XTMF2/ModelSystemConstruct/GhostNode.cs
@@ -0,0 +1,203 @@
+/*
+ Copyright 2026 University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+
+namespace XTMF2.ModelSystemConstruct;
+
+///
+/// A visual alias for another that may reside on a different
+/// .
+///
+/// Ghost nodes always mirror the name of their referenced node, expose no hooks,
+/// and are rendered on the canvas with a dashed outline. They can be link
+/// destinations (links drawn to them are displayed on the canvas) but
+/// they never act as link origins. When the real node is deleted all ghost nodes
+/// that reference it are automatically removed.
+///
+///
+public sealed class GhostNode : Node
+{
+ // ── JSON property names (only what is unique to GhostNode) ───────
+ internal const string ReferencedNodeProperty = "ReferencedNode";
+
+ /// The real node that this ghost node visually represents.
+ public Node ReferencedNode { get; }
+
+ ///
+ /// Creates a ghost node that mirrors ,
+ /// placed at inside .
+ ///
+ internal GhostNode(Node referencedNode, Boundary containedWithin, Rectangle location)
+ : base(referencedNode.Name, null!, containedWithin, Array.Empty(), location)
+ {
+ ReferencedNode = referencedNode;
+ // Track name changes on the referenced node.
+ ((INotifyPropertyChanged)referencedNode).PropertyChanged += OnReferencedNodePropertyChanged;
+ }
+
+ private void OnReferencedNodePropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(Name))
+ {
+ Name = ReferencedNode.Name;
+ // The Name setter in Node already fires PropertyChanged for Name.
+ }
+ }
+
+ ///
+ /// Ghost nodes do not write themselves in a boundary's Nodes array.
+ /// They are indexed here (so links can reference them) and serialised
+ /// by in a dedicated GhostNodes array.
+ ///
+ internal override void Save(ref int index, Dictionary nodeDictionary,
+ Dictionary typeDictionary, Utf8JsonWriter writer)
+ {
+ // Index this ghost node so links can reference it by index.
+ // The actual JSON object is written separately by Boundary.SaveGhostNodes.
+ if (!nodeDictionary.TryGetValue(this, out _))
+ nodeDictionary[this] = index++;
+ }
+
+ ///
+ /// Writes the standalone JSON object for this ghost node.
+ /// Called by during save after all regular nodes and child
+ /// boundaries have been assigned their indices.
+ ///
+ internal void SaveObject(Dictionary nodeDictionary, Utf8JsonWriter writer)
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber(ReferencedNodeProperty, nodeDictionary[ReferencedNode]);
+ writer.WriteNumber(XProperty, Location.X);
+ writer.WriteNumber(YProperty, Location.Y);
+ writer.WriteNumber(WidthProperty, Location.Width);
+ writer.WriteNumber(HeightProperty, Location.Height);
+ writer.WriteNumber(IndexProperty, nodeDictionary[this]);
+ writer.WriteEndObject();
+ }
+
+ ///
+ /// Reads a ghost node entry from JSON and appends a deferred-resolution record.
+ /// The ghost node is fully constructed later by once all
+ /// node indices are available.
+ ///
+ internal static bool LoadDeferred(
+ ref Utf8JsonReader reader,
+ Boundary boundary,
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferreds,
+ [NotNullWhen(false)] ref string? error)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ error = "Expected a start object when loading a ghost node.";
+ return false;
+ }
+
+ int refIndex = -1, selfIndex = -1;
+ Rectangle location = new Rectangle();
+
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType == JsonTokenType.Comment) continue;
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ error = "Invalid token when loading a ghost node.";
+ return false;
+ }
+
+ if (reader.ValueTextEquals(ReferencedNodeProperty))
+ {
+ reader.Read();
+ refIndex = reader.GetInt32();
+ }
+ else if (reader.ValueTextEquals(XProperty))
+ {
+ reader.Read();
+ location = new Rectangle(reader.GetSingle(), location.Y, location.Width, location.Height);
+ }
+ else if (reader.ValueTextEquals(YProperty))
+ {
+ reader.Read();
+ location = new Rectangle(location.X, reader.GetSingle(), location.Width, location.Height);
+ }
+ else if (reader.ValueTextEquals(WidthProperty))
+ {
+ reader.Read();
+ location = new Rectangle(location.X, location.Y, reader.GetSingle(), location.Height);
+ }
+ else if (reader.ValueTextEquals(HeightProperty))
+ {
+ reader.Read();
+ location = new Rectangle(location.X, location.Y, location.Width, reader.GetSingle());
+ }
+ else if (reader.ValueTextEquals(IndexProperty))
+ {
+ reader.Read();
+ selfIndex = reader.GetInt32();
+ }
+ else
+ {
+ // Skip unknown fields for forward compatibility.
+ reader.Read();
+ }
+ }
+
+ if (refIndex < 0)
+ {
+ error = "Ghost node is missing its referenced node index.";
+ return false;
+ }
+ if (selfIndex < 0)
+ {
+ error = "Ghost node is missing its own index.";
+ return false;
+ }
+
+ deferreds.Add((boundary, refIndex, selfIndex, location));
+ return true;
+ }
+
+ ///
+ /// Resolves a previously deferred ghost node, creating the object and
+ /// inserting it into the global dictionary.
+ ///
+ internal static bool Resolve(
+ Dictionary nodes,
+ Boundary containedIn,
+ int refIndex,
+ int selfIndex,
+ Rectangle location,
+ [NotNullWhen(true)] out GhostNode? ghost,
+ [NotNullWhen(false)] ref string? error)
+ {
+ if (!nodes.TryGetValue(refIndex, out var refNode))
+ {
+ ghost = null;
+ error = $"Ghost node references unknown node index {refIndex}.";
+ return false;
+ }
+
+ ghost = new GhostNode(refNode, containedIn, location);
+ nodes[selfIndex] = ghost;
+ return true;
+ }
+}
diff --git a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs
index 300235f..7b2b40c 100644
--- a/src/XTMF2/ModelSystemConstruct/ModelSystem.cs
+++ b/src/XTMF2/ModelSystemConstruct/ModelSystem.cs
@@ -211,6 +211,9 @@ private Dictionary WriteBoundaries(Utf8JsonWriter writer, Dictionary<
writer.WritePropertyName(BoundariesProperty);
writer.WriteStartArray();
Dictionary nodeDictionary = new Dictionary();
+ // Pre-assign indices for ALL nodes (including ghost nodes that may reference
+ // siblings) so that link serialisation can reference any node by index.
+ GlobalBoundary.PreAssignNodeIndices(ref index, nodeDictionary);
GlobalBoundary.Save(ref index, nodeDictionary, typeDictionary, writer);
writer.WriteEndArray();
return nodeDictionary;
@@ -335,6 +338,7 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH
var typeLookup = new Dictionary();
var nodes = new Dictionary();
List<(Node toAssignTo, string parameterExpression)> scriptedParameters = new();
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes = new();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.PropertyName)
@@ -348,7 +352,7 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH
}
else if (reader.ValueTextEquals(BoundariesProperty))
{
- if (!LoadBoundaries(modules, typeLookup, nodes, scriptedParameters, ref reader, modelSystem.GlobalBoundary, ref error))
+ if (!LoadBoundaries(modules, typeLookup, nodes, scriptedParameters, deferredGhostNodes, ref reader, modelSystem.GlobalBoundary, ref error))
{
return null;
}
@@ -363,6 +367,16 @@ internal static bool Load(ProjectSession session, ModelSystemHeader modelSystemH
// Unknown properties are silently skipped for forward compatibility.
}
}
+ // Resolve deferred ghost nodes now that all boundaries and nodes are loaded.
+ foreach (var (containedIn, refIndex, selfIndex, location) in deferredGhostNodes)
+ {
+ if (!GhostNode.Resolve(nodes, containedIn, refIndex, selfIndex, location, out var ghost, ref error))
+ {
+ // Non-fatal: skip ghost nodes that can't be resolved (e.g. referenced node was removed).
+ continue;
+ }
+ containedIn.AddGhostNode(ghost!, out _);
+ }
// Now that all of the modules have been loaded we can process the scripted parameters
foreach (var (toAssignTo, parameterExpression) in scriptedParameters)
{
@@ -462,7 +476,9 @@ private static bool LoadTypes(Dictionary typeLookup, ref Utf8JsonRead
}
private static bool LoadBoundaries(ModuleRepository modules, Dictionary typeLookup, Dictionary nodes,
- List<(Node toAssignTo, string parameterExpression)> scriptedParameters, ref Utf8JsonReader reader, Boundary global, [NotNullWhen(false)] ref string? error)
+ List<(Node toAssignTo, string parameterExpression)> scriptedParameters,
+ List<(Boundary ContainedIn, int RefIndex, int SelfIndex, Rectangle Location)> deferredGhostNodes,
+ ref Utf8JsonReader reader, Boundary global, [NotNullWhen(false)] ref string? error)
{
if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
{
@@ -474,7 +490,7 @@ private static bool LoadBoundaries(ModuleRepository modules, Dictionary moduleDictionary, Utf8JsonWrit
internal override bool Construct(ref string? error)
{
- var moduleCount = _Destinations.Count(d => !d.IsDisabled);
+ // Count enabled destinations, resolving ghost nodes to their real targets.
+ var moduleCount = _Destinations.Count(d =>
+ {
+ var effective = d is GhostNode gn ? gn.ReferencedNode : d;
+ return !effective.IsDisabled;
+ });
if(OriginHook!.Cardinality == HookCardinality.AtLeastOne)
{
if (moduleCount <= 0)
@@ -101,9 +106,10 @@ internal override bool Construct(ref string? error)
int index = 0;
for (int i = 0; i < _Destinations.Count; i++)
{
- if (!_Destinations[i].IsDisabled)
+ var effectiveDest = _Destinations[i] is GhostNode gn ? gn.ReferencedNode : _Destinations[i];
+ if (!effectiveDest.IsDisabled)
{
- OriginHook.Install(Origin!, _Destinations[i], index++);
+ OriginHook.Install(Origin!, effectiveDest, index++);
}
}
}
diff --git a/src/XTMF2/ModelSystemConstruct/Node.cs b/src/XTMF2/ModelSystemConstruct/Node.cs
index b467bf1..471cbf1 100644
--- a/src/XTMF2/ModelSystemConstruct/Node.cs
+++ b/src/XTMF2/ModelSystemConstruct/Node.cs
@@ -38,6 +38,15 @@ public class Node : INotifyPropertyChanged
///
public Boundary ContainedWithin { get; protected set; }
+ ///
+ /// Transfers this node to a different containing boundary.
+ /// Only called by during move operations.
+ ///
+ internal void UpdateContainedWithin(Boundary newBoundary)
+ {
+ ContainedWithin = newBoundary;
+ }
+
protected const string NameProperty = "Name";
protected const string DescriptionProperty = "Description";
protected const string XProperty = "X";
@@ -371,7 +380,12 @@ internal bool GetLink(NodeHook hook, out Link? link)
internal virtual void Save(ref int index, Dictionary moduleDictionary, Dictionary typeDictionary, Utf8JsonWriter writer)
{
- moduleDictionary.Add(this, index);
+ // Support pre-indexed nodes (PreAssignNodeIndices was called before Save).
+ if (!moduleDictionary.TryGetValue(this, out var myIndex))
+ {
+ myIndex = index++;
+ moduleDictionary[this] = myIndex;
+ }
writer.WriteStartObject();
writer.WriteString(NameProperty, Name);
writer.WriteString(DescriptionProperty, Description);
@@ -380,7 +394,7 @@ internal virtual void Save(ref int index, Dictionary moduleDictionary
writer.WriteNumber(YProperty, Location.Y);
writer.WriteNumber(WidthProperty, Location.Width);
writer.WriteNumber(HeightProperty, Location.Height);
- writer.WriteNumber(IndexProperty, index++);
+ writer.WriteNumber(IndexProperty, myIndex);
if (ParameterValue is not null)
{
ParameterValue.Save(writer);
diff --git a/src/XTMF2/ModelSystemConstruct/SingleLink.cs b/src/XTMF2/ModelSystemConstruct/SingleLink.cs
index 68a01aa..8aefb59 100644
--- a/src/XTMF2/ModelSystemConstruct/SingleLink.cs
+++ b/src/XTMF2/ModelSystemConstruct/SingleLink.cs
@@ -58,10 +58,13 @@ internal override void Save(Dictionary moduleDictionary, Utf8JsonWrit
internal override bool Construct(ref string? error)
{
+ // Resolve ghost-node destinations to their real node at runtime.
+ var effectiveDest = Destination is GhostNode gn ? gn.ReferencedNode : Destination!;
+
// if not optional
if (OriginHook!.Cardinality == HookCardinality.Single)
{
- if (Destination!.IsDisabled)
+ if (effectiveDest.IsDisabled)
{
error = "A link destined for a disabled module was not optional.";
return false;
@@ -75,7 +78,7 @@ internal override bool Construct(ref string? error)
if (!IsDisabled)
{
// The index doesn't matter for this type
- OriginHook.Install(Origin!, Destination!, 0);
+ OriginHook.Install(Origin!, effectiveDest, 0);
}
return true;
}
diff --git a/src/XTMF2/ModelSystemConstruct/Start.cs b/src/XTMF2/ModelSystemConstruct/Start.cs
index 49e1faf..c7eb435 100644
--- a/src/XTMF2/ModelSystemConstruct/Start.cs
+++ b/src/XTMF2/ModelSystemConstruct/Start.cs
@@ -43,11 +43,16 @@ public Start(ModuleRepository modules, string startName, Boundary boundary, stri
internal override void Save(ref int index, Dictionary moduleDictionary, Dictionary typeDictionary, Utf8JsonWriter writer)
{
- moduleDictionary.Add(this, index);
+ // Support pre-indexed nodes (PreAssignNodeIndices was called before Save).
+ if (!moduleDictionary.TryGetValue(this, out var myIndex))
+ {
+ myIndex = index++;
+ moduleDictionary[this] = myIndex;
+ }
writer.WriteStartObject();
writer.WriteString(NameProperty, Name);
writer.WriteString(DescriptionProperty, Description);
- writer.WriteNumber(IndexProperty, index++);
+ writer.WriteNumber(IndexProperty, myIndex);
writer.WriteNumber(XProperty, Location.X);
writer.WriteNumber(YProperty, Location.Y);
writer.WriteEndObject();
diff --git a/src/XTMF2/RuntimeModules/ScriptedParameter.cs b/src/XTMF2/RuntimeModules/ScriptedParameter.cs
index ad3297d..fb7ebdf 100644
--- a/src/XTMF2/RuntimeModules/ScriptedParameter.cs
+++ b/src/XTMF2/RuntimeModules/ScriptedParameter.cs
@@ -65,5 +65,19 @@ private void ThrowGotNull()
{
throw new XTMFRuntimeException(this, $"Unable to get a {typeof(T).FullName} value from expression '{Expression.Representation}'!");
}
+
+ public override bool RuntimeValidation(ref string? error)
+ {
+ if(!base.RuntimeValidation(ref error))
+ {
+ return false;
+ }
+ if (Expression is null)
+ {
+ error = "Expression is not set!";
+ return false;
+ }
+ return true;
+ }
}
}