Files
2026-07-13 13:13:17 +08:00

504 lines
23 KiB
C#

using ImGuiNET;
using T3.Core.Operator;
using T3.Editor.Gui.MagGraph.Model;
using T3.Editor.Gui.MagGraph.States;
using T3.Editor.Gui.OutputUi;
using T3.Editor.UiModel;
using T3.Editor.UiModel.Commands;
using T3.Editor.UiModel.Commands.Sections;
using T3.Editor.UiModel.Commands.Graph;
using T3.Editor.UiModel.InputsAndTypes;
using T3.Editor.UiModel.Modification;
using T3.Editor.UiModel.Selection;
namespace T3.Editor.Gui.MagGraph.Interaction;
internal static class Modifications
{
/// <summary>
/// Duplicates the selected operators while preserving their external wiring: connections feeding the
/// selection from the outside are recreated on the duplicates, and any external multi-inputs the originals
/// fed get the duplicates' outputs appended — pushing snapped items below the new input line down, like an
/// interactive connection drop. Single external inputs are left untouched so the duplicate doesn't steal
/// the original's connection. Pastes at the mouse position and does not touch the clipboard.
/// </summary>
internal static void DuplicateWithConnections(GraphUiContext context)
{
var compositionOp = context.CompositionInstance;
var compositionSymbolUi = compositionOp.GetSymbolUi();
var compositionSymbol = compositionSymbolUi.Symbol;
var selectedChildUis = context.Selector.GetSelectedChildUis().ToList();
if (selectedChildUis.Count == 0)
return;
var selectedSections = context.Selector.GetSelectedNodes<Section>().ToList();
var targetPosition = context.View.InverseTransformPositionFloat(ImGui.GetMousePos());
var copyCommand = new CopySymbolChildrenCommand(compositionSymbolUi,
selectedChildUis,
selectedSections,
compositionSymbolUi,
targetPosition);
// Authoritative set of duplicated children (includes any pulled in via collapsed sections).
var oldToNewChildIds = copyCommand.OldToNewChildIds;
// Connections feeding the selection from outside, kept with their original multi-input index so
// inserting them ascending lands each one at its original slot among the copied internal connections.
var inboundCommands = new List<(int multiInputIndex, AddConnectionCommand command)>();
// Outputs of the selection feeding external multi-inputs, appended right after the original connection.
// The original connection is kept for the push-down of snapped items below its input line.
var outboundEntries = new List<(Symbol.Connection original, int multiInputIndex, AddConnectionCommand command)>();
foreach (var connection in compositionSymbol.Connections)
{
var sourceDuplicated = connection.SourceParentOrChildId != Guid.Empty
&& oldToNewChildIds.ContainsKey(connection.SourceParentOrChildId);
var targetDuplicated = connection.TargetParentOrChildId != Guid.Empty
&& oldToNewChildIds.ContainsKey(connection.TargetParentOrChildId);
var multiInputIndex = compositionSymbol.GetMultiInputIndexFor(connection);
if (targetDuplicated && !sourceDuplicated)
{
// External source -> duplicated target (also covers connections from the composition's own inputs).
var newConnection = new Symbol.Connection(connection.SourceParentOrChildId,
connection.SourceSlotId,
oldToNewChildIds[connection.TargetParentOrChildId],
connection.TargetSlotId);
inboundCommands.Add((multiInputIndex, new AddConnectionCommand(compositionSymbol, newConnection, multiInputIndex)));
}
else if (sourceDuplicated && !targetDuplicated && compositionSymbol.IsTargetMultiInput(connection))
{
// Duplicated source -> external multi-input. Single inputs are skipped on purpose: adding the
// duplicate's output there would replace (steal) the original's connection.
var newConnection = new Symbol.Connection(oldToNewChildIds[connection.SourceParentOrChildId],
connection.SourceSlotId,
connection.TargetParentOrChildId,
connection.TargetSlotId);
outboundEntries.Add((connection, multiInputIndex, new AddConnectionCommand(compositionSymbol, newConnection, multiInputIndex + 1)));
}
}
// Inbound ascending: each insert at the original index reconstructs the source order on duplicated inputs.
inboundCommands.Sort((a, b) => a.multiInputIndex.CompareTo(b.multiInputIndex));
// Outbound descending: inserting at (originalIndex + 1) high-to-low keeps earlier indices valid as the
// multi-input grows.
outboundEntries.Sort((a, b) => b.multiInputIndex.CompareTo(a.multiInputIndex));
var macroCommand = context.StartMacroCommand("Duplicate with connections");
macroCommand.AddAndExecCommand(copyCommand);
foreach (var entry in inboundCommands)
{
macroCommand.AddAndExecCommand(entry.command);
}
foreach (var entry in outboundEntries)
{
macroCommand.AddAndExecCommand(entry.command);
}
// Each appended multi-input connection adds an input line to its target: push snapped items below
// the new line down by one row, like an interactive connection drop. The layout still reflects the
// pre-duplication state here, which is exactly the geometry the thresholds need.
foreach (var entry in outboundEntries)
{
if (!context.Layout.Items.TryGetValue(entry.original.TargetParentOrChildId, out var targetItem))
continue;
var lines = targetItem.InputLines;
var originalLineIndex = -1;
for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{
if (lines[lineIndex].Id == entry.original.TargetSlotId
&& lines[lineIndex].MultiInputIndex == entry.multiInputIndex)
{
originalLineIndex = lineIndex;
break;
}
}
if (originalLineIndex == -1)
continue;
var snappedItems = MagItemMovement.CollectSnappedItems(targetItem);
snappedItems.Remove(targetItem);
MagItemMovement.MoveSnappedItemsVertically(context,
snappedItems,
targetItem.PosOnCanvas.Y + MagGraphItem.GridSize.Y * (originalLineIndex + 0.5f),
MagGraphItem.GridSize.Y);
}
// Duplicating into a section near its bottom/right border grows the frame to fit
SectionTree.GrowSectionToFitPlacedItems(compositionSymbolUi, targetPosition, copyCommand.NewSymbolChildIds, context.MacroCommand!, context.Selector);
context.CompleteMacroCommand();
// Select the duplicated nodes
context.Selector.Clear();
foreach (var newId in copyCommand.NewSymbolChildIds)
{
if (compositionSymbolUi.ChildUis.TryGetValue(newId, out var newChildUi)
&& compositionOp.Children.TryGetChildInstance(newId, out var instance))
{
context.Selector.AddSelection(newChildUi, instance);
}
}
foreach (var newId in copyCommand.NewSymbolSectionIds)
{
if (compositionSymbolUi.Sections.TryGetValue(newId, out var section))
{
context.Selector.AddSelection(section);
}
}
}
internal static ChangeSymbol.SymbolModificationResults AlignSelectionToLeft(GraphUiContext context)
{
var results = ChangeSymbol.SymbolModificationResults.Nothing;
if (context.Selector.Selection.Count == 0)
return results;
if(!SymbolUiRegistry.TryGetSymbolUi(context.CompositionInstance.Symbol.Id, out var compositionUi))
{
Log.Warning("Can't find composition ui?");
return results;
}
var minX = float.PositiveInfinity;
foreach (var i in context.Selector.Selection)
{
minX = MathF.Min(minX, i.PosOnCanvas.X);
}
var moveCommand = new ModifyCanvasElementsCommand(compositionUi.Symbol.Id, context.Selector.Selection, context.Selector );
foreach (var i in context.Selector.Selection)
{
var pos = i.PosOnCanvas;
pos.X = minX;
i.PosOnCanvas= pos;
}
moveCommand.StoreCurrentValues();
UndoRedoStack.Add(moveCommand);
return ChangeSymbol.SymbolModificationResults.Nothing;
}
/// <summary>
/// Deletes the selected items and tries to collapse the gaps and patches the connection gaps is possible
/// </summary>
/// <param name="context"></param>
internal static ChangeSymbol.SymbolModificationResults DeleteSelection(GraphUiContext context)
{
var results = ChangeSymbol.SymbolModificationResults.Nothing;
if (context.Selector.Selection.Count == 0)
return results;
if(!SymbolUiRegistry.TryGetSymbolUi(context.CompositionInstance.Symbol.Id, out var compositionUi))
{
Log.Warning("Can't find composition ui?");
return results;
}
var deletedItems = new List<MagGraphItem>();
var deletedChildUis = new List<SymbolUi.Child>();
var deletedInputUis = new List<IInputUi>();
var deletedOutputUis = new List<IOutputUi>();
var deletedSections = new List<MagGraphSection>();
foreach (var s in context.Selector.Selection)
{
if (context.Layout.Items.TryGetValue(s.Id, out var item))
{
if (item.Variant != MagGraphItem.Variants.Operator)
{
if (compositionUi.Symbol.SymbolPackage.IsReadOnly)
{
Log.Warning("Can't delete inputs or outputs from a read only symbol.");
continue;
}
if (item.Variant == MagGraphItem.Variants.Input)
{
deletedInputUis.Add(item.Selectable as IInputUi);
}
else if (item.Variant == MagGraphItem.Variants.Output)
{
deletedOutputUis.Add(item.Selectable as IOutputUi);
}
continue;
}
if (!compositionUi.ChildUis.TryGetValue(item.Id, out var childUi))
{
Log.Warning("Can't find symbol child for " + item);
continue;
}
//uiItems.Add(new ItemWithChildUi(item, childUi));
deletedItems.Add(item);
deletedChildUis.Add(childUi);
}
else if (s is Section section)
{
if (!context.Layout.Sections.TryGetValue(s.Id, out var mga))
{
Log.Warning("Can't find section: " + s.Id);
continue;
}
deletedSections.Add(mga);
}
else
{
Log.Warning("Can't find selectable item " + s);
continue;
}
}
// Deleting a collapsed frame deletes its hidden contents with it - once the
// header is gone they would have no visible representation left on the canvas
if (deletedSections.Count > 0)
{
var hiddenOps = new List<SymbolUi.Child>();
var hiddenSections = new List<Section>();
foreach (var magSection in deletedSections.ToArray())
{
if (magSection.Section.Collapsed)
{
SectionTree.CollectHiddenContents(compositionUi, magSection.Section, hiddenOps, hiddenSections);
}
}
foreach (var hiddenOp in hiddenOps)
{
if (deletedChildUis.Contains(hiddenOp))
continue;
deletedChildUis.Add(hiddenOp);
if (context.Layout.Items.TryGetValue(hiddenOp.Id, out var hiddenItem))
{
deletedItems.Add(hiddenItem);
}
}
foreach (var hiddenSection in hiddenSections)
{
if (!context.Layout.Sections.TryGetValue(hiddenSection.Id, out var hiddenMagSection))
{
Log.Warning("Can't find hidden nested section: " + hiddenSection.Id);
continue;
}
if (!deletedSections.Contains(hiddenMagSection))
{
deletedSections.Add(hiddenMagSection);
}
}
}
// find obsolete connections...
var obsoleteConnections = new HashSet<MagGraphConnection>();
foreach (var i in deletedItems)
{
foreach (var inLine in i.InputLines)
{
if (inLine.ConnectionIn != null)
obsoleteConnections.Add(inLine.ConnectionIn);
}
foreach (var outLine in i.OutputLines)
{
obsoleteConnections.UnionWith(outLine.ConnectionsOut);
}
}
if (deletedChildUis.Count == 0 && deletedInputUis.Count == 0 && deletedOutputUis.Count == 0
&& deletedSections.Count==0)
return results;
var macroCommand = new MacroCommand("Delete items");
if (deletedChildUis.Count > 0)
{
var collapsableConnectionPairs= MagItemMovement.FindLinkedVerticalCollapsableConnectionPairs(obsoleteConnections.ToList());
// The bridge index must be computed before the deletion below removes
// other connections into the same multi-input and shifts the positions.
var deletedChildIds = new HashSet<Guid>();
foreach (var childUi in deletedChildUis)
{
deletedChildIds.Add(childUi.Id);
}
var bridgeMultiInputIndices = new List<int>(collapsableConnectionPairs.Count);
foreach (var pair in collapsableConnectionPairs)
{
bridgeMultiInputIndices.Add(ComputeMultiInputIndexAfterDeletion(context.CompositionInstance.Symbol, pair.Cb, deletedChildIds));
}
// Delete items...
macroCommand.AddAndExecCommand(new DeleteSymbolChildrenCommand(compositionUi, deletedChildUis));
// Collapse vertical gaps
var relevantItems = MagItemMovement.CollectSnappedItems(deletedItems);
var movableItems = new HashSet<MagGraphItem>(relevantItems.Except(deletedItems));
for (var pairIndex = 0; pairIndex < collapsableConnectionPairs.Count; pairIndex++)
{
var pair = collapsableConnectionPairs[pairIndex];
var mci = pair.Ca;
var mco = pair.Cb;
var affectedItems = MagItemMovement.MoveToCollapseVerticalGaps(mci, mco, movableItems, true);
if (affectedItems.Count == 0)
continue;
var affectedItemsAsNodes = movableItems.Select(i => i as ISelectableCanvasObject).ToList();
var newMoveCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, affectedItemsAsNodes, context.Selector);
macroCommand.AddExecutedCommandForUndo(newMoveCommand);
MagItemMovement.MoveToCollapseVerticalGaps(mci, mco, movableItems, dryRun:false);
newMoveCommand.StoreCurrentValues();
macroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol,
new Symbol.Connection(mci.SourceItem.Id,
mci.SourceOutput.Id,
mco.TargetItem.Id,
mco.TargetInput.Id),
bridgeMultiInputIndices[pairIndex]));
}
// Removing a connection can also free an input row on a surviving target
// (e.g. an extra multi-input line): move snapped items below up to close the gap.
var rebridgedConnections = new HashSet<MagGraphConnection>();
foreach (var pair in collapsableConnectionPairs)
{
rebridgedConnections.Add(pair.Ca);
rebridgedConnections.Add(pair.Cb);
}
foreach (var obsoleteConnection in obsoleteConnections)
{
if (rebridgedConnections.Contains(obsoleteConnection))
continue;
var targetItem = obsoleteConnection.TargetItem;
if (targetItem == null || deletedItems.Contains(targetItem))
continue;
var lines = targetItem.InputLines;
var lineIndex = obsoleteConnection.InputLineIndex;
if (lineIndex >= lines.Length)
continue;
var line = lines[lineIndex];
var linesForSlot = 0;
foreach (var otherLine in lines)
{
if (otherLine.Id == line.Id)
linesForSlot++;
}
// The row only disappears for extra multi-input lines and for optional inputs shown while connected
var freesInputLine = linesForSlot > 1
|| (targetItem.Variant == MagGraphItem.Variants.Operator
&& line.InputUi is { Relevancy: Relevancy.Optional }
&& lineIndex > 0);
if (!freesInputLine)
continue;
var snappedItems = MagItemMovement.CollectSnappedItems(targetItem);
var yThreshold = targetItem.PosOnCanvas.Y + MagGraphItem.GridSize.Y * (lineIndex - 0.5f);
var itemsBelow = new List<ISelectableCanvasObject>();
foreach (var snappedItem in snappedItems)
{
if (snappedItem != targetItem
&& !deletedItems.Contains(snappedItem)
&& snappedItem.PosOnCanvas.Y > yThreshold)
{
itemsBelow.Add(snappedItem);
}
}
if (itemsBelow.Count == 0)
continue;
var moveUpCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, itemsBelow, context.Selector);
macroCommand.AddExecutedCommandForUndo(moveUpCommand);
foreach (var itemBelow in itemsBelow)
{
var pos = itemBelow.PosOnCanvas;
pos.Y -= MagGraphItem.GridSize.Y;
itemBelow.PosOnCanvas = pos;
}
moveUpCommand.StoreCurrentValues();
}
}
if (deletedInputUis.Count > 0 || deletedOutputUis.Count > 0)
{
// Executed after the child deletes above, so the command only captures the connections
// still present (child-side connections were already removed by DeleteSymbolChildrenCommand).
macroCommand.AddAndExecCommand(new RemoveInputsOrOutputsCommand(compositionUi.Symbol.Id,
deletedInputUis.Select(entry => entry.Id).ToArray(),
deletedOutputUis.Select(entry => entry.Id).ToArray()));
}
if (deletedSections.Count > 0)
{
foreach (var a in deletedSections)
{
macroCommand.AddAndExecCommand(new DeleteSectionCommand(compositionUi, a.Section));
}
}
UndoRedoStack.Add(macroCommand);
context.Layout.FlagStructureAsChanged();
context.Selector.Clear();
context.Layout.FlagStructureAsChanged();
return results;
}
//private record ItemWithChildUi(MagGraphItem Item, SymbolUi.Child UiChild);
/// <summary>
/// Computes the multi-input index a bridging connection should be inserted at once the
/// given children are deleted: the number of surviving connections into the same target
/// slot that precede the replaced connection.
/// </summary>
private static int ComputeMultiInputIndexAfterDeletion(Symbol symbol, MagGraphConnection replacedConnection, HashSet<Guid> deletedChildIds)
{
var survivingIndex = 0;
var slotConnectionIndex = 0;
foreach (var connection in symbol.Connections)
{
if (connection.TargetParentOrChildId != replacedConnection.TargetItem.Id
|| connection.TargetSlotId != replacedConnection.TargetInput.Id)
continue;
if (slotConnectionIndex == replacedConnection.MultiInputIndex)
break;
slotConnectionIndex++;
if (!deletedChildIds.Contains(connection.SourceParentOrChildId)
&& !deletedChildIds.Contains(connection.TargetParentOrChildId))
{
survivingIndex++;
}
}
return survivingIndex;
}
}