#nullable enable using T3.Editor.Gui.MagGraph.Model; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel.Selection; namespace T3.Editor.UiModel; /// /// Shared helpers for resolving the section structure of a symbol graph: nesting, /// membership, and collapse visibility. Graph views, the dope sheet, and the snapshot /// control view should all derive section structure from here instead of re-deriving /// it from geometry. /// internal static class SectionTree { internal sealed class Node { internal required Section Section; internal readonly List Children = []; internal readonly List Members = []; } /// /// Builds the section tree with member ops in display order (canvas position, /// top-to-bottom then left-to-right). Ops outside any section end up in /// . /// internal static List Build(SymbolUi symbolUi, List? unsectionedOps = null) { var nodesById = new Dictionary(symbolUi.Sections.Count); foreach (var section in symbolUi.Sections.Values) { nodesById[section.Id] = new Node { Section = section }; } var roots = new List(); foreach (var node in nodesById.Values) { if (nodesById.TryGetValue(node.Section.ParentSectionId, out var parentNode) && parentNode != node) { parentNode.Children.Add(node); } else { roots.Add(node); } } foreach (var childUi in symbolUi.ChildUis.Values) { if (nodesById.TryGetValue(childUi.SectionId, out var node)) { node.Members.Add(childUi); } else { unsectionedOps?.Add(childUi); } } foreach (var node in nodesById.Values) { node.Members.Sort(CompareByCanvasPosition); } return roots; } /// /// Derives ownership from canvas geometry: ops belong to the innermost section fully /// containing them, sections nest into the innermost strictly larger one. Called on /// load and on every structural layout refresh — ownership is derived state, so undoing /// a move restores it implicitly and no command needs to track it. /// /// Collapsed sections neither adopt nor release: only their header renders, so the rest /// of their stored rect is invisible and geometry against it would silently hide loose /// ops dropped there. Ownership of hidden ops and hidden nested frames is fully sticky — /// they can't be moved by the user, so re-deriving it (e.g. after their collapsed frame /// was pushed across another section) would silently hand them over. /// internal static void UpdateOwnershipFromGeometry(SymbolUi symbolUi) { if (symbolUi.Sections.Count == 0 && symbolUi.ChildUis.Count == 0) return; foreach (var childUi in symbolUi.ChildUis.Values) { // Ops hidden in a collapsed frame keep their ownership unconditionally: they are // invisible and can't be moved by the user, so when the collapsed frame gets // pushed across another section, geometry must not hand them over. if (symbolUi.Sections.TryGetValue(childUi.SectionId, out var owner) && IsSelfOrAncestorCollapsed(symbolUi, owner)) continue; var rect = new ImRect(childUi.PosOnCanvas, childUi.PosOnCanvas + childUi.Size); childUi.SectionId = FindOwnerSectionId(symbolUi, rect, currentOwnerId: childUi.SectionId, excludeId: Guid.Empty, requireLargerThan: 0f); } foreach (var section in symbolUi.Sections.Values) { // Same stickiness for nesting: frames hidden under a collapsed ancestor keep their parent if (symbolUi.Sections.TryGetValue(section.ParentSectionId, out var parent) && IsSelfOrAncestorCollapsed(symbolUi, parent)) continue; // A collapsed frame nests by its visible header footprint, not its stored expanded // rect - dropping a collapsed frame into a section should parent it; expanding it // later grows the parent via the bounds-expansion cascade. var visibleSize = section.Collapsed ? new Vector2(section.Size.X, MagGraphItem.LineHeight) : section.Size; var rect = ImRect.RectWithSize(section.PosOnCanvas, visibleSize); section.ParentSectionId = FindParentSectionId(symbolUi, rect, currentOwnerId: section.ParentSectionId, excludeId: section.Id); } } /// /// Recomputes for all children. /// Call after structural changes (collapse toggles, ownership changes, section deletes). /// internal static void UpdateCollapsedVisibility(SymbolUi symbolUi) { foreach (var childUi in symbolUi.ChildUis.Values) { childUi.HiddenInCollapsedSectionId = FindOutermostCollapsedSection(symbolUi, childUi.SectionId); } // Nested frames hide with their collapsed ancestors; a collapsed section // itself stays visible as its header foreach (var section in symbolUi.Sections.Values) { section.HiddenInCollapsedSectionId = FindOutermostCollapsedSection(symbolUi, section.ParentSectionId); } } /// /// Collects everything that moves with a section: member ops, nested sections with /// their members, and inputs/outputs contained geometrically (those have no ownership). /// internal static void CollectSectionContents(SymbolUi symbolUi, Section section, List results) { _visitedSectionIds.Clear(); _visitedSectionIds.Add(section.Id); CollectMembersRecursively(symbolUi, section, results); var sectionRect = new ImRect(section.PosOnCanvas, section.PosOnCanvas + section.Size); foreach (var inputUi in symbolUi.InputUis.Values) { if (sectionRect.Contains(new ImRect(inputUi.PosOnCanvas, inputUi.PosOnCanvas + inputUi.Size))) results.Add(inputUi); } foreach (var outputUi in symbolUi.OutputUis.Values) { if (sectionRect.Contains(new ImRect(outputUi.PosOnCanvas, outputUi.PosOnCanvas + outputUi.Size))) results.Add(outputUi); } } /// /// Collects the member ops and nested frames hidden inside a collapsed section, /// recursively. Used when deleting a collapsed frame: once its header is gone the /// hidden contents have no visible representation left, so they are deleted with it. /// internal static void CollectHiddenContents(SymbolUi symbolUi, Section section, List hiddenOps, List
hiddenSections) { _visitedSectionIds.Clear(); _visitedSectionIds.Add(section.Id); CollectHiddenContentsRecursively(symbolUi, section, hiddenOps, hiddenSections); } internal readonly record struct OverlapPush(ISelectableCanvasObject Block, Vector2 Delta); /// /// Computes how far sibling frames in the source's parent scope (with their contents) /// must move along (a unit /// axis) so nothing overlaps . Pushes cascade to blocks /// shoved into others; the monotone relaxation is iteration-capped so cyclic /// arrangements can't loop forever. Growing the parent to fit is the caller's job — /// see . /// internal static void CollectOverlapPushes(SymbolUi symbolUi, Section source, ImRect claimedBounds, Vector2 pushDirection, List results) { _pushBlocks.Clear(); CollectScopeBlocksIntoBuffer(symbolUi, source); RunPushRelaxation(claimedBounds, allowDown: pushDirection.Y > 0, allowRight: pushDirection.X > 0); foreach (var block in _pushBlocks) { if (block.PushDistance > 0) { results.Add(new OverlapPush(block.Root, block.PushAxis * block.PushDistance)); } } } /// /// Snapshot of the sibling blocks the solver would consider for the given source — /// used to freeze original bounds for stateless per-frame push previews. /// internal static void CollectScopeBlocks(SymbolUi symbolUi, Section source, List<(ISelectableCanvasObject Root, ImRect Bounds)> blocks) { _pushBlocks.Clear(); CollectScopeBlocksIntoBuffer(symbolUi, source); foreach (var block in _pushBlocks) { blocks.Add((block.Root, block.Bounds)); } } /// /// Push deltas against frozen block bounds, one delta per block index (zero when /// unaffected). Each block resolves along a single axis - the one needing the smaller /// displacement when both are allowed. Recomputed from the same original bounds every /// frame, this yields a stateless preview: blocks return when the border retreats. /// internal static void ComputePushesForBlocks(IReadOnlyList<(ISelectableCanvasObject Root, ImRect Bounds)> blocks, ImRect claimedBounds, bool allowDown, bool allowRight, List deltasPerBlock) { _pushBlocks.Clear(); foreach (var (root, bounds) in blocks) { _pushBlocks.Add(new PushBlock(root, bounds)); } RunPushRelaxation(claimedBounds, allowDown, allowRight); deltasPerBlock.Clear(); foreach (var block in _pushBlocks) { deltasPerBlock.Add(block.PushAxis * block.PushDistance); } } private static void CollectScopeBlocksIntoBuffer(SymbolUi symbolUi, Section source) { // Only frames are pushed - loose ops are never moved by the solver. Shoving ops // around would wreck hand-made layouts (and the relaxation would teleport them // across large frames). An op overlapped by an expanding frame is simply adopted: // visible inside the frame and reversible by dragging it out. var scopeId = source.ParentSectionId; foreach (var section in symbolUi.Sections.Values) { // Only sibling frames in the same scope move as blocks; nested ones travel with them if (section.Id == source.Id || section.ParentSectionId != scopeId) continue; // Collapsed neighbors only occupy their visible header strip var size = section.Collapsed ? new Vector2(section.Size.X, MagGraphItem.LineHeight) : section.Size; _pushBlocks.Add(new PushBlock(section, ImRect.RectWithSize(section.PosOnCanvas, size))); } } private static void RunPushRelaxation(ImRect claimedBounds, bool allowDown, bool allowRight) { _pushQueue.Clear(); _pushQueue.Enqueue((claimedBounds, null)); var remainingIterations = (_pushBlocks.Count + 1) * 4; while (_pushQueue.Count > 0 && remainingIterations-- > 0) { var (claimed, claimSource) = _pushQueue.Dequeue(); for (var index = 0; index < _pushBlocks.Count; index++) { var block = _pushBlocks[index]; // A block's own claim must not push it again - a rect always overlaps itself if (block == claimSource) continue; var rect = block.Bounds; rect.Translate(block.PushAxis * block.PushDistance); if (!claimed.Overlaps(rect)) continue; // Each block resolves along a single axis, chosen on first contact by the // smaller displacement. Blocks starting before the claim on an axis can't // be pushed along it - they surround or precede the claim there; that's a // container relationship handled by nesting. if (block.PushDistance <= 0) { var canPushDown = allowDown && block.Bounds.Min.Y >= claimed.Min.Y; var canPushRight = allowRight && block.Bounds.Min.X >= claimed.Min.X; if (!canPushDown && !canPushRight) continue; var requiredDown = claimed.Max.Y + PushPadding - block.Bounds.Min.Y; var requiredRight = claimed.Max.X + PushPadding - block.Bounds.Min.X; block.PushAxis = canPushDown && (!canPushRight || requiredDown <= requiredRight) ? Vector2.UnitY : Vector2.UnitX; } var required = block.PushAxis.Y > 0 ? claimed.Max.Y + PushPadding - block.Bounds.Min.Y : claimed.Max.X + PushPadding - block.Bounds.Min.X; if (required <= block.PushDistance) continue; block.PushDistance = required; var newRect = block.Bounds; newRect.Translate(block.PushAxis * required); _pushQueue.Enqueue((newRect, block)); } } } /// /// Pushes neighboring blocks out of the given claimed bounds and folds the applied /// move into as an executed command. No-op when nothing /// overlaps. Pushed sections take their members and nested frames along. /// internal static void PushNeighborsOutOfBounds(SymbolUi symbolUi, Section source, ImRect claimedBounds, Vector2 pushDirection, Commands.MacroCommand macro, ISelection selector) { _overlapPushes.Clear(); CollectOverlapPushes(symbolUi, source, claimedBounds, pushDirection, _overlapPushes); if (_overlapPushes.Count == 0) return; _elementsToPush.Clear(); _pushDeltas.Clear(); foreach (var push in _overlapPushes) { var firstIndex = _elementsToPush.Count; _elementsToPush.Add(push.Block); if (push.Block is Section pushedSection) { CollectSectionContents(symbolUi, pushedSection, _elementsToPush); } for (var i = firstIndex; i < _elementsToPush.Count; i++) { _pushDeltas.Add(push.Delta); } } var moveCommand = new Commands.Graph.ModifyCanvasElementsCommand(symbolUi, _elementsToPush, selector); for (var i = 0; i < _elementsToPush.Count; i++) { _elementsToPush[i].PosOnCanvas += _pushDeltas[i]; } moveCommand.StoreCurrentValues(); macro.AddExecutedCommandForUndo(moveCommand); } /// /// Resolves the fallout of a frame claiming more space (e.g. expanding from collapse): /// siblings in the same parent scope get pushed along , /// the parent grows (bottom/right) to keep its content inside, and that growth cascades /// further up the ancestor chain. All moves and resizes fold into the macro. /// internal static void ResolveBoundsExpansion(SymbolUi symbolUi, Section source, ImRect claimedBounds, Vector2 pushDirection, Commands.MacroCommand macro, ISelection selector) { var current = source; var claimed = claimedBounds; var remainingLevels = symbolUi.Sections.Count; // guards against parent-reference cycles while (remainingLevels-- >= 0) { PushNeighborsOutOfBounds(symbolUi, current, claimed, pushDirection, macro, selector); if (!symbolUi.Sections.TryGetValue(current.ParentSectionId, out var parent)) break; // Grow the parent so its content (including the pushed siblings) stays inside var contentMax = new Vector2(float.NegativeInfinity, float.NegativeInfinity); var hasContent = false; foreach (var sibling in symbolUi.Sections.Values) { if (sibling.ParentSectionId != parent.Id) continue; var size = sibling.Collapsed ? new Vector2(sibling.Size.X, MagGraphItem.LineHeight) : sibling.Size; contentMax = Vector2.Max(contentMax, sibling.PosOnCanvas + size); hasContent = true; } foreach (var childUi in symbolUi.ChildUis.Values) { if (childUi.SectionId != parent.Id) continue; contentMax = Vector2.Max(contentMax, childUi.PosOnCanvas + childUi.Size); hasContent = true; } if (!hasContent) break; var parentMax = parent.PosOnCanvas + parent.Size; var requiredMax = Vector2.Max(parentMax, contentMax + ContentPadding); if (requiredMax.X <= parentMax.X + 0.5f && requiredMax.Y <= parentMax.Y + 0.5f) break; // parent still fits - nothing propagates further up var resizeCommand = new Commands.Graph.ModifyCanvasElementsCommand(symbolUi, [parent], selector); parent.Size = requiredMax - parent.PosOnCanvas; resizeCommand.StoreCurrentValues(); macro.AddExecutedCommandForUndo(resizeCommand); claimed = ImRect.RectWithSize(parent.PosOnCanvas, parent.Size); current = parent; } } /// /// After placing new ops (paste, duplicate) around a target position inside a section, /// grows that section so items overflowing its bottom/right border stay inside and /// pushes neighbors out of the claimed area — folded into the given macro. No-op when /// the target position isn't inside a top-level expanded section or nothing overflows. /// internal static void GrowSectionToFitPlacedItems(SymbolUi symbolUi, Vector2 targetPosOnCanvas, IReadOnlyList newChildIds, Commands.MacroCommand macro, ISelection selector) { // Innermost expanded frame containing the placement anchor Section? target = null; var targetArea = float.PositiveInfinity; foreach (var section in symbolUi.Sections.Values) { if (section.Collapsed || section.IsHiddenInCollapsedSection) continue; var rect = ImRect.RectWithSize(section.PosOnCanvas, section.Size); if (!rect.Contains(targetPosOnCanvas)) continue; var area = section.Size.X * section.Size.Y; if (area >= targetArea) continue; targetArea = area; target = section; } if (target == null) return; // Only measure pasted ops that actually landed inside the target frame. When a whole // section is pasted, the anchor can fall inside a pasted sub-section (the target) whose // members are scattered across the entire pasted region - measuring all of them would // balloon the target and cascade a huge push through every neighbor. var targetRect = ImRect.RectWithSize(target.PosOnCanvas, target.Size); var itemsMax = new Vector2(float.NegativeInfinity, float.NegativeInfinity); var anyItems = false; foreach (var id in newChildIds) { if (!symbolUi.ChildUis.TryGetValue(id, out var childUi)) continue; // Top-left inside means the op started in this frame and may overflow its bottom/right border if (!targetRect.Contains(childUi.PosOnCanvas)) continue; itemsMax = Vector2.Max(itemsMax, childUi.PosOnCanvas + childUi.Size); anyItems = true; } if (!anyItems) return; var boundsMax = target.PosOnCanvas + target.Size; var requiredMax = Vector2.Max(boundsMax, itemsMax + ContentPadding); var grewX = requiredMax.X > boundsMax.X + 0.5f; var grewY = requiredMax.Y > boundsMax.Y + 0.5f; if (!grewX && !grewY) return; var resizeCommand = new Commands.Graph.ModifyCanvasElementsCommand(symbolUi, [target], selector); target.Size = requiredMax - target.PosOnCanvas; resizeCommand.StoreCurrentValues(); macro.AddExecutedCommandForUndo(resizeCommand); var newBounds = ImRect.RectWithSize(target.PosOnCanvas, target.Size); if (grewY) ResolveBoundsExpansion(symbolUi, target, newBounds, Vector2.UnitY, macro, selector); if (grewX) ResolveBoundsExpansion(symbolUi, target, newBounds, Vector2.UnitX, macro, selector); } /// Margin kept between section content and the frame border when auto-growing. internal static readonly Vector2 ContentPadding = new(15, 15); private sealed class PushBlock(ISelectableCanvasObject root, ImRect bounds) { internal readonly ISelectableCanvasObject Root = root; internal readonly ImRect Bounds = bounds; internal Vector2 PushAxis = Vector2.UnitY; internal float PushDistance; } /// /// Nesting is forgiving: a frame stays parented while the majority of its visible /// footprint lies inside the candidate. Full containment would silently un-nest a /// child poking slightly over the border, turning it into an overlapping "sibling" /// that the overlap solver may then push out entirely. /// private static Guid FindParentSectionId(SymbolUi symbolUi, ImRect rect, Guid currentOwnerId, Guid excludeId) { var ownArea = rect.GetWidth() * rect.GetHeight(); Section? best = null; var bestArea = float.PositiveInfinity; foreach (var candidate in symbolUi.Sections.Values) { if (candidate.Id == excludeId) continue; // Collapsed frames (and frames hidden inside one) only keep their current // children, they never adopt - their bounds are invisible if (candidate.Id != currentOwnerId && IsSelfOrAncestorCollapsed(symbolUi, candidate)) continue; // Strictly-larger requirement prevents identically-sized sections from parenting each other var candidateArea = candidate.Size.X * candidate.Size.Y; if (candidateArea <= ownArea || candidateArea >= bestArea) continue; var candidateRect = ImRect.RectWithSize(candidate.PosOnCanvas, candidate.Size); var overlapWidth = MathF.Min(rect.Max.X, candidateRect.Max.X) - MathF.Max(rect.Min.X, candidateRect.Min.X); var overlapHeight = MathF.Min(rect.Max.Y, candidateRect.Max.Y) - MathF.Max(rect.Min.Y, candidateRect.Min.Y); if (overlapWidth <= 0 || overlapHeight <= 0) continue; if (overlapWidth * overlapHeight < ownArea * 0.5f) continue; bestArea = candidateArea; best = candidate; } return best?.Id ?? Guid.Empty; } private static Guid FindOwnerSectionId(SymbolUi symbolUi, ImRect rect, Guid currentOwnerId, Guid excludeId, float requireLargerThan) { Section? best = null; var bestArea = float.PositiveInfinity; foreach (var section in symbolUi.Sections.Values) { if (section.Id == excludeId) continue; // Collapsed sections (and frames hidden inside one) only keep their // current members, they never adopt — their bounds are invisible if (section.Id != currentOwnerId && IsSelfOrAncestorCollapsed(symbolUi, section)) continue; var area = new ImRect(section.PosOnCanvas, section.PosOnCanvas + section.Size); if (!area.Contains(rect)) continue; var size = section.Size.X * section.Size.Y; if (size <= requireLargerThan || size >= bestArea) continue; bestArea = size; best = section; } return best?.Id ?? Guid.Empty; } private static void CollectHiddenContentsRecursively(SymbolUi symbolUi, Section section, List hiddenOps, List
hiddenSections) { foreach (var childUi in symbolUi.ChildUis.Values) { if (childUi.SectionId == section.Id) hiddenOps.Add(childUi); } foreach (var nested in symbolUi.Sections.Values) { if (nested.ParentSectionId != section.Id) continue; // Visited set guards against parent-reference cycles in hand-edited files if (!_visitedSectionIds.Add(nested.Id)) continue; hiddenSections.Add(nested); CollectHiddenContentsRecursively(symbolUi, nested, hiddenOps, hiddenSections); } } private static void CollectMembersRecursively(SymbolUi symbolUi, Section section, List results) { foreach (var childUi in symbolUi.ChildUis.Values) { if (childUi.SectionId == section.Id) results.Add(childUi); } foreach (var nested in symbolUi.Sections.Values) { if (nested.ParentSectionId != section.Id) continue; // Visited set guards against parent-reference cycles in hand-edited files if (!_visitedSectionIds.Add(nested.Id)) continue; results.Add(nested); CollectMembersRecursively(symbolUi, nested, results); } } private static bool IsSelfOrAncestorCollapsed(SymbolUi symbolUi, Section section) { if (section.Collapsed) return true; return FindOutermostCollapsedSection(symbolUi, section.ParentSectionId) != Guid.Empty; } private static Guid FindOutermostCollapsedSection(SymbolUi symbolUi, Guid sectionId) { var result = Guid.Empty; var remainingSteps = symbolUi.Sections.Count; // guards against parent-reference cycles while (sectionId != Guid.Empty && remainingSteps-- >= 0) { if (!symbolUi.Sections.TryGetValue(sectionId, out var section)) break; if (section.Collapsed) result = section.Id; sectionId = section.ParentSectionId; } return result; } private static int CompareByCanvasPosition(SymbolUi.Child a, SymbolUi.Child b) { var byY = a.PosOnCanvas.Y.CompareTo(b.PosOnCanvas.Y); return byY != 0 ? byY : a.PosOnCanvas.X.CompareTo(b.PosOnCanvas.X); } private const float PushPadding = 10; private static readonly HashSet _visitedSectionIds = []; private static readonly List _pushBlocks = []; private static readonly Queue<(ImRect Rect, PushBlock? Source)> _pushQueue = new(); private static readonly List _overlapPushes = []; private static readonly List _elementsToPush = []; private static readonly List _pushDeltas = []; }