From 835b00fa1e8e36a74cca1235ee021cc9fd669be6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 19:53:56 -0400 Subject: [PATCH 01/23] docs: correct stale HierarchyHandling.Recursive and LayoutGraphPort scaffolding docs (Stage 0) --- .../engine/layered-pipeline.md | 5 ++-- .../Engine/Layered/HierarchyHandling.cs | 25 ++++++++++++++--- .../Graph/LayoutGraphPort.cs | 28 ++++++++++--------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index 24f00fb..4617903 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -42,8 +42,9 @@ Each stage implements `ILayoutStage` (`void Apply(LayeredGraph graph)`) and muta place. Stages are stateless and may be shared across pipelines. `LayeredLayoutMetrics` holds the shared spacing, clearance, and padding constants — intentionally identical to the constants of the previous monolithic engine so the pipeline reproduces its output exactly. The `LayoutDirection` enum -selects Right, Down, Left, or Up flow; `HierarchyHandling` selects Flat (supported) or Recursive -(reserved). +selects Right, Down, Left, or Up flow; `HierarchyHandling` selects `Flat` (a single flat pass) or the +ELK-style `Recursive` compound-graph mode used for edges whose named boundary port crosses a container +boundary. #### Layered Pipeline Assembly diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs index 91a8eff..e79e7d3 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs @@ -8,15 +8,32 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// How a layered layout treats nested (compound) nodes. /// /// -/// Only is exercised by the current behavior-preserving extraction; -/// is reserved scaffolding for a later phase that lays out each -/// container's children bottom-up and treats each container as a fixed-size atomic node. +/// +/// runs the pipeline once over a single flat graph and is the mode every +/// container-free layout uses. +/// +/// +/// selects the ELK-style compound-graph handling used when an edge with a +/// named boundary port crosses a container boundary: the portion of the node hierarchy the +/// crossing actually touches is flattened into one graph, each boundary crossing is represented as +/// a hierarchy-crossing dummy node that participates in the same single layer-assignment and +/// crossing-minimization pass as ordinary long-edge dummies, and each container's own size is still +/// reconciled bottom-up (children sized first) before the joint pass positions the crossing point +/// consistently with both the container's placement among its siblings and its children's placement +/// inside it. The merge region is general and transitive: it is the minimal footprint +/// touched by boundary-crossing edges — not fixed at two levels and not one boundary port at a +/// time — so it follows a chain of delegation ports to arbitrary depth and merges any number of +/// independent boundary ports on one container together in a single combined pass. +/// /// internal enum HierarchyHandling { /// Run the pipeline once over a single flat graph. Flat, - /// Run the flat pipeline per container, bottom-up (reserved for a later phase). + /// + /// Flatten the portion of the hierarchy touched by boundary-crossing port edges into one graph and + /// resolve each crossing as a hierarchy-crossing dummy in a single combined layered pass. + /// Recursive, } diff --git a/src/DemaConsulting.Rendering/Graph/LayoutGraphPort.cs b/src/DemaConsulting.Rendering/Graph/LayoutGraphPort.cs index f2d5d07..2cb2821 100644 --- a/src/DemaConsulting.Rendering/Graph/LayoutGraphPort.cs +++ b/src/DemaConsulting.Rendering/Graph/LayoutGraphPort.cs @@ -18,13 +18,14 @@ namespace DemaConsulting.Rendering; /// via . /// /// -/// This first-cut (Phase 1, flat-graph) model deliberately has no Side property: no -/// caller control over which edge of the node a port is placed on exists yet, and no -/// hierarchy-aware placement (internal vs. external edges through a container) is implemented — -/// is the only label rendered in this phase; -/// is carried for forward compatibility but is not read anywhere yet. The type does not forbid -/// more than one internal or external edge per port, but the first-cut scope only exercises at -/// most one of each. +/// This model deliberately has no Side property: no caller control over which edge of the +/// node a port is placed on exists yet, so the layout algorithm resolves each port's side from the +/// placed geometry of its connected edge(s). is rendered for an edge +/// that crosses into or out of the node from outside its container; is +/// rendered for a delegation edge into the node's own child scope when the port is a genuine +/// boundary port. Both labels may be present on one port (a boundary/delegation port), and a single +/// port may carry more than one external and more than one internal edge — fan-out is supported, +/// the several edges on a face sharing the port's single anchor. /// /// public sealed class LayoutGraphPort : PropertyHolder, ILayoutConnectable @@ -47,16 +48,17 @@ public LayoutGraphPort(string id) /// /// Gets or sets the text label rendered beside this port for an edge that crosses into or out of /// the node from outside its container (an external edge). when - /// no label should be shown. This is the only label rendered by the flat-graph (Phase 1) layout - /// algorithm, since no ancestor scope exists yet to make an edge "internal" by comparison. + /// no label should be shown. On a plain (non-boundary) port this is the only label present and is + /// rendered inward beside the port symbol; on a boundary port (one that also carries an + /// ) it is rendered on the outward face. /// public string? ExternalLabel { get; set; } /// - /// Gets or sets the text label associated with this port for an edge contained entirely within - /// the node's own child scope (an internal edge). when no label - /// should be shown. Reserved for a future hierarchy-aware layout phase; unused and never read by - /// the Phase 1 flat-graph layout algorithm. + /// Gets or sets the text label rendered beside this port for a delegation edge into the node's own + /// child scope (an internal edge). when no label should be shown. + /// Its presence marks the port as a genuine boundary port and drives the boundary-crossing layout + /// and outward placement of . /// public string? InternalLabel { get; set; } } From 5c223e968698053b0bb0963085cf80dc8e485d99 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 20:02:02 -0400 Subject: [PATCH 02/23] feat: split LayoutPort.Label into ExternalLabel/InternalLabel; renderers place outward external label on boundary ports (Stage 1) --- .../LayeredLayoutAlgorithm.cs | 4 +- .../SkiaRasterRenderer.cs | 67 ++++++++++++++----- .../SvgRenderer.cs | 57 ++++++++++++---- .../LayoutTree/LayoutPort.cs | 28 +++++--- .../HierarchicalLayoutAlgorithmTests.cs | 2 +- .../LayeredLayoutAlgorithmTests.cs | 6 +- .../LayoutTests.cs | 3 +- 7 files changed, 121 insertions(+), 46 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index 9f3e4ea..8ab2f8c 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -412,7 +412,7 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) anchor.Y, ResolveSide(anchor, result.Rects[emission.Source]), emission.SourcePort.ExternalLabel, - ResolveMaxLabelWidth(result.Rects[emission.Source]))); + MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Source]))); } if (emission.TargetPort != null) @@ -423,7 +423,7 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) anchor.Y, ResolveSide(anchor, result.Rects[emission.Target]), emission.TargetPort.ExternalLabel, - ResolveMaxLabelWidth(result.Rects[emission.Target]))); + MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Target]))); } } diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index 768f248..59f2455 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -1255,28 +1255,61 @@ private static void RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions o canvas.DrawRect(portRect, outlinePaint); } - // Draw the optional label inward, toward the box interior, so it reads immediately next to - // the port glyph without overlapping the connector approaching from outside the box. - if (port.Label != null) + // Labels. InternalLabel (when present) always renders inward, toward the box interior. The + // ExternalLabel renders inward too when there is no InternalLabel (a plain, non-boundary port — + // byte-identical to the single-label behavior), and only on the outward face when an + // InternalLabel is also present (a genuine boundary port), mirroring the inward offset across + // the port centre. + if (port.InternalLabel != null) { - // Offset the label far enough from the port square so it does not overlap - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; - var (labelX, labelY, align) = port.Side switch - { - PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), - PortSide.Bottom => (port.CentreX, port.CentreY - offset, SKTextAlign.Center), - PortSide.Left => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Left), - _ => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Right) - }; + DrawPortLabel(canvas, port, port.InternalLabel, port.Side, options); + } - using var textPaint = CreateTextPaint(strokeColor); - using var font = CreateFont((float)theme.FontSizeBody * scale, bold: false, italic: false); - var maxLabelWidth = (float)(port.MaxLabelWidth * scale); - font.Size = FitFontSize(font, port.Label, maxLabelWidth, font.Size); - canvas.DrawText(port.Label, (float)(labelX * scale), (float)(labelY * scale), align, font, textPaint); + if (port.ExternalLabel != null) + { + var side = port.InternalLabel != null ? OppositeSide(port.Side) : port.Side; + DrawPortLabel(canvas, port, port.ExternalLabel, side, options); } } + /// Returns the box edge opposite , used to place an outward label. + private static PortSide OppositeSide(PortSide side) => side switch + { + PortSide.Top => PortSide.Bottom, + PortSide.Bottom => PortSide.Top, + PortSide.Left => PortSide.Right, + _ => PortSide.Left, + }; + + /// + /// Draws one port label offset from the port centre using the interior-side formula for + /// (the port's own side for an inward label, the opposite side for an + /// outward one), so an inward and an outward label on one boundary port sit symmetrically about the + /// port centre. + /// + private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, PortSide offsetSide, RenderOptions options) + { + var theme = options.Theme; + var scale = (float)options.Scale; + var strokeColor = SKColor.Parse(theme.StrokeColor); + + // Offset the label far enough from the port square so it does not overlap. + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var (labelX, labelY, align) = offsetSide switch + { + PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), + PortSide.Bottom => (port.CentreX, port.CentreY - offset, SKTextAlign.Center), + PortSide.Left => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Left), + _ => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, SKTextAlign.Right) + }; + + using var textPaint = CreateTextPaint(strokeColor); + using var font = CreateFont((float)theme.FontSizeBody * scale, bold: false, italic: false); + var maxLabelWidth = (float)(port.MaxLabelWidth * scale); + font.Size = FitFontSize(font, text, maxLabelWidth, font.Size); + canvas.DrawText(text, (float)(labelX * scale), (float)(labelY * scale), align, font, textPaint); + } + /// /// Renders a as the specified icon shape centered at the badge /// position. An optional label is drawn to the right of the bounding circle. diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index bcd5ae0..b4b8a27 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -941,25 +941,54 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d $""" """); sb.AppendLine(); - // Optional label offset inward, toward the box interior, so it reads immediately next to - // the port glyph without overlapping the connector approaching from outside the box. - if (port.Label != null) + // Labels. InternalLabel (when present) always renders inward, toward the box interior, beside + // the port glyph. ExternalLabel renders inward too whenever there is no InternalLabel (a plain, + // non-boundary port — byte-identical to the single-label behavior every existing call site + // relies on), and only renders on the outward face when an InternalLabel is also present (a + // genuine boundary port), mirroring the inward offset across the port centre. + if (port.InternalLabel != null) { - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; - var (labelX, labelY, anchor) = port.Side switch - { - PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), - PortSide.Bottom => (port.CentreX, port.CentreY - offset, TextAnchorMiddle), - PortSide.Left => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, "start"), - _ => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, "end") - }; + EmitPortLabel(sb, port, port.InternalLabel, port.Side, theme, scale); + } - sb.Append(CultureInfo.InvariantCulture, - $""" {EscapeXml(port.Label)}"""); - sb.AppendLine(); + if (port.ExternalLabel != null) + { + var side = port.InternalLabel != null ? OppositeSide(port.Side) : port.Side; + EmitPortLabel(sb, port, port.ExternalLabel, side, theme, scale); } } + /// Returns the box edge opposite , used to place an outward label. + private static PortSide OppositeSide(PortSide side) => side switch + { + PortSide.Top => PortSide.Bottom, + PortSide.Bottom => PortSide.Top, + PortSide.Left => PortSide.Right, + _ => PortSide.Left, + }; + + /// + /// Emits one port <text> label offset from the port centre toward the interior of the + /// box on (which equals the port's own side for an inward label and the + /// opposite side for an outward one), using the same offset formula for both so an inward and an + /// outward label on one boundary port sit symmetrically about the port centre. + /// + private static void EmitPortLabel(StringBuilder sb, LayoutPort port, string text, PortSide offsetSide, Theme theme, double scale) + { + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var (labelX, labelY, anchor) = offsetSide switch + { + PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), + PortSide.Bottom => (port.CentreX, port.CentreY - offset, TextAnchorMiddle), + PortSide.Left => (port.CentreX + offset, port.CentreY + theme.FontSizeBody / 2.0, "start"), + _ => (port.CentreX - offset, port.CentreY + theme.FontSizeBody / 2.0, "end") + }; + + sb.Append(CultureInfo.InvariantCulture, + $""" {EscapeXml(text)}"""); + sb.AppendLine(); + } + /// /// Renders a as shape-specific SVG elements centered at the /// badge position, with an optional <text> label to the right. diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs index 71d592d..bef6105 100644 --- a/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs @@ -28,18 +28,30 @@ public enum PortSide /// Absolute X coordinate of the port centre in logical pixels. /// Absolute Y coordinate of the port centre in logical pixels. /// Edge of the parent box that this port is attached to. -/// Optional text label displayed beside the port symbol. +/// +/// Optional label for the edge crossing into or out of the box from outside its container. On a plain +/// (non-boundary) port — one whose is — this is +/// rendered inward, beside the port glyph, exactly as the single legacy label was. On a +/// boundary port (one that also carries an ) it is rendered on the +/// outward face instead. when no external label should be shown. +/// +/// +/// Optional label for the delegation edge into the box's own child scope. Always rendered inward +/// beside the port glyph. Its presence marks this as a genuine boundary port and moves +/// to the outward face. on a plain port, which +/// preserves the exact legacy rendering of every pre-existing call site. +/// /// -/// Maximum width, in logical pixels, a renderer should allow to occupy -/// before squeezing it (the same way a box title is squeezed to fit). Computed by the layout -/// algorithm from the owning box's width, since a has no direct reference -/// to its owning . Defaults to (no -/// constraint), which preserves the behavior of every pre-existing 4-argument -/// new LayoutPort(x, y, side, label) call site. +/// Maximum width, in logical pixels, a renderer should allow either label to occupy before squeezing it +/// (the same way a box title is squeezed to fit). Computed by the layout algorithm from the owning box's +/// width, since a has no direct reference to its owning . +/// Defaults to (no constraint), which preserves the behavior of +/// every pre-existing 4-argument new LayoutPort(x, y, side, label) call site. /// public sealed record LayoutPort( double CentreX, double CentreY, PortSide Side, - string? Label, + string? ExternalLabel, + string? InternalLabel = null, double MaxLabelWidth = double.PositiveInfinity) : LayoutNode; diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index 1e23edd..2ba45f1 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -618,7 +618,7 @@ public void Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePo // one LayoutLine connects the two boxes — the connector is routed, not dropped, even though // the scope contains an (unrelated) container. var port1 = Assert.Single(tree.Nodes.OfType()); - Assert.Equal("output", port1.Label); + Assert.Equal("output", port1.ExternalLabel); Assert.Single(tree.Nodes.OfType()); // The port anchor lies exactly on the source box's boundary, matching how a flat (no diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index bb85995..3829560 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -711,7 +711,7 @@ public void Apply_EdgeWithPortEndpoint_EmitsLayoutPortWithExternalLabel() // Assert: exactly one port is emitted, carrying the external label. var ports = tree.Nodes.OfType().ToList(); var port1 = Assert.Single(ports); - Assert.Equal("output", port1.Label); + Assert.Equal("output", port1.ExternalLabel); // The port anchor lies exactly on the source box's boundary (its right face, since the // port is the edge's source feeding rightward toward the target). @@ -847,8 +847,8 @@ public void Apply_NodeWithLongLeftAndRightPortLabels_BothSidesAvoidSqueeze() var measuredRightWidth = PortLabelWidthEstimator.MeasureWidth(rightLabel, assumedFontSize); var ports = tree.Nodes.OfType().ToList(); - var inLayoutPort = Assert.Single(ports, p => p.Label == leftLabel); - var outLayoutPort = Assert.Single(ports, p => p.Label == rightLabel); + var inLayoutPort = Assert.Single(ports, p => p.ExternalLabel == leftLabel); + var outLayoutPort = Assert.Single(ports, p => p.ExternalLabel == rightLabel); Assert.True( inLayoutPort.MaxLabelWidth >= measuredLeftWidth, $"Expected left MaxLabelWidth >= {measuredLeftWidth}, was {inLayoutPort.MaxLabelWidth}."); diff --git a/test/DemaConsulting.Rendering.Tests/LayoutTests.cs b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs index 6942e0f..0be92ee 100644 --- a/test/DemaConsulting.Rendering.Tests/LayoutTests.cs +++ b/test/DemaConsulting.Rendering.Tests/LayoutTests.cs @@ -179,7 +179,8 @@ public void LayoutPort_Construction_StoresAllFields() Assert.Equal(250.0, port.CentreX); Assert.Equal(150.0, port.CentreY); Assert.Equal(PortSide.Right, port.Side); - Assert.Equal("myPort", port.Label); + Assert.Equal("myPort", port.ExternalLabel); + Assert.Null(port.InternalLabel); } /// From 2d361fe48029c673352a59eaf3836b5610940c3d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 20:32:49 -0400 Subject: [PATCH 03/23] Add hierarchy-crossing descriptor and recursive pipeline path Extend AugNode with an optional HierarchyCrossing descriptor (originating LayoutGraphPort + External/Internal face) keeping default construction byte-identical for existing callers. Enable the LayeredLayoutPipeline Recursive path by removing the NotSupportedException throw and adding AddRecursiveStages(); the Flat stage list is unchanged. --- .../Engine/Layered/LayeredGraph.cs | 51 ++++++++++++++++++- .../Engine/Layered/LayeredLayoutPipeline.cs | 33 +++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index fdf5a06..4b4abe2 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -4,12 +4,59 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; -/// A node in the augmented Sugiyama graph (real part box or long-edge dummy). +/// +/// Identifies which face of a boundary container's box a hierarchy-crossing dummy represents. +/// +/// +/// A boundary port carries up to two logical connection faces at the same physical anchor: the +/// face is where an edge crossing in from a sibling scope approaches the +/// container from outside, and the face is where a delegation edge into the +/// container's own child scope departs on the inside. Both faces resolve to a single shared anchor +/// on the container boundary; the enum records which logical half a given hierarchy-crossing dummy +/// stands in for, so the resolver can spread and reconcile the two halves consistently. +/// +internal enum HierarchyCrossingFace +{ + /// The outward-facing half, approached by an edge crossing in from a sibling scope. + External, + + /// The inward-facing half, departed by a delegation edge into the container's child scope. + Internal, +} + +/// +/// Describes a hierarchy-crossing dummy: the extra data an carries when it stands +/// in for a boundary port crossing a container boundary, rather than an ordinary long-edge dummy. +/// +/// +/// Generalizes LongEdgeSplitter's zero-size intermediate-layer dummy from "spans layers +/// within one scope" to "spans layers across nested scopes": a hierarchy-crossing dummy participates +/// in the same layer-assignment, crossing-minimization, and placement pass as ordinary dummies, but +/// additionally remembers the originating and which of the +/// container boundary it represents, so the placed dummy coordinate can be reconciled with the +/// owning container's own box placement into a single shared boundary anchor. +/// +/// The originating input-graph boundary port this dummy stands in for. +/// Which logical face (external/internal) of the boundary crossing this dummy is. +internal readonly record struct HierarchyCrossing(LayoutGraphPort Port, HierarchyCrossingFace Face); + +/// A node in the augmented Sugiyama graph (real part box, long-edge dummy, or hierarchy-crossing dummy). /// Width of the node's bounding box in logical pixels. /// Height of the node's bounding box in logical pixels. /// Assigned Sugiyama layer index. /// Whether this node is a zero-size long-edge dummy. -internal sealed record AugNode(double Width, double Height, int Layer, bool IsDummy = false); +/// +/// When non-, marks this node as a hierarchy-crossing dummy standing in for a +/// boundary port (rather than an ordinary long-edge dummy), carrying the originating port and its face. +/// for every real node and every ordinary long-edge dummy, so the default +/// construction and every existing caller stay byte-identical. +/// +internal sealed record AugNode( + double Width, + double Height, + int Layer, + bool IsDummy = false, + HierarchyCrossing? Crossing = null); /// A sub-edge in the augmented graph after long-edge splitting. /// Index of the source augmented node. diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs index 67f2dbf..b88d662 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs @@ -111,16 +111,37 @@ public PipelineBuilder AddDefaultStages() return this; } + /// + /// Appends the ELK-layered stage sequence used for recursive (compound-graph) hierarchy + /// handling. It is the same Sugiyama stage sequence as , because a + /// hierarchy-crossing dummy participates in exactly the same layer-assignment, + /// crossing-minimization, and placement stages as an ordinary node or long-edge dummy — ELK's + /// own compound-graph design. The Recursive-specific behavior is not a distinct stage but the + /// way the caller seeds the graph with zero-size hierarchy-crossing dummies (carrying a + /// descriptor) and reads their placed positions back out; this + /// method exists so a Recursive pipeline is assembled through its own explicit entry point, + /// keeping the Flat path untouched. + /// + /// This builder, for chaining. + public PipelineBuilder AddRecursiveStages() + { + // A hierarchy-crossing dummy is a zero-size node that flows through the standard stages + // like any other node, so the recursive pass reuses the identical stage sequence rather + // than introducing a parallel, divergent one that could drift from the Flat path. + return AddDefaultStages(); + } + /// Builds the configured pipeline. /// A new . + /// + /// Both and are + /// supported: Flat runs the stage sequence over a single flat graph, while Recursive runs the + /// same stage sequence over a graph pre-seeded with hierarchy-crossing dummies. The mode is + /// retained on the built pipeline () so callers can + /// assert which contract a pipeline was assembled for. + /// public LayeredLayoutPipeline Build() { - if (_hierarchy == HierarchyHandling.Recursive) - { - throw new NotSupportedException( - "Recursive hierarchy handling is not yet supported."); - } - return new LayeredLayoutPipeline(_direction, _hierarchy, _stages.ToArray()); } } From aa5ccf57c6f0efda49f39073e92c1ba12e8128ed Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 20:32:57 -0400 Subject: [PATCH 04/23] Add boundary-port merge-region builder and resolver HierarchyMergeRegionBuilder detects boundary (delegation) ports structurally per scope and transitively across a whole hierarchy. BoundaryPortResolver reconciles each boundary port into one shared anchor carrying both labels, consolidates external fan-out, and wires one internal delegation connector per edge, ordering same-face crossings via the recursive layered pipeline. --- .../Engine/Layered/BoundaryPortResolver.cs | 483 ++++++++++++++++++ .../Layered/HierarchyMergeRegionBuilder.cs | 164 ++++++ 2 files changed, 647 insertions(+) create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyMergeRegionBuilder.cs diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs new file mode 100644 index 0000000..97d46f6 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs @@ -0,0 +1,483 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// The placed connectors and enriched port anchors a pass produces +/// for one container scope. +/// +/// +/// The scope's final port list: the leaf pass's ports with each boundary port's anchor enriched to +/// carry both its external and internal labels and any external fan-out anchors consolidated to a +/// single shared anchor, plus a synthesized anchor for every delegation port the leaf pass could not +/// anchor on its own. +/// +/// +/// The scope's final connector list: the leaf pass's lines (with external fan-out lines re-terminated +/// onto the shared anchor) plus one new orthogonal connector per internal delegation edge, each +/// reaching the boundary port's single shared anchor. +/// +internal sealed record BoundaryResolution( + IReadOnlyList Ports, + IReadOnlyList Lines); + +/// +/// Resolves boundary (delegation) ports for one container scope into a single shared anchor per port, +/// carrying both the external and internal labels, and wires every external and internal edge that +/// converges on the port to that one anchor — the placement half of the ELK-style recursive hierarchy +/// handling. +/// +/// +/// +/// A boundary port must resolve to exactly one physical anchor on its container's boundary so the +/// external approach (routed by the parent scope's leaf pass) and the internal delegation (routed +/// here, into the container's own placed children) meet consistently. When the leaf pass already +/// anchored the port — because its external edges are ordinary sibling edges it could route — that +/// leaf anchor is authoritative and is simply enriched with the internal label; any additional +/// external fan-out anchors the leaf spread across the face are consolidated back onto it. When the +/// leaf pass could not anchor the port — because the port is a further link in a delegation chain +/// whose external edge is the parent container's own boundary port — a single anchor is synthesized +/// on the boundary face facing the flow, its position derived from a combined layered pass over the +/// hierarchy-crossing dummies and interior targets so multiple ports on one face never collide. +/// +/// +/// This type never runs for a scope with no boundary ports (the caller gates on a non-empty +/// result), so every +/// boundary-port-free graph keeps its existing, byte-identical output. +/// +/// +internal static class BoundaryPortResolver +{ + /// Tolerance, in logical pixels, for deciding an anchor point lies on a box face. + private const double BoundaryTolerance = 0.1; + + /// Clearance, in logical pixels, bounding a synthesized anchor's port label width. + private const double PortLabelClearance = 4.0; + + /// + /// Resolves every boundary port of against the scope's already-composed, + /// already-placed geometry, producing the final port and connector lists. + /// + /// The container scope whose boundary ports are resolved. + /// The scope's resolved flow direction, used to pick the boundary face. + /// The boundary ports discovered by . + /// The scope's composed top-level boxes, aligned with its nodes by index. + /// Map from each direct-member node to its index in . + /// The leaf pass's emitted ports for this scope. + /// The leaf pass's emitted connector lines for this scope. + /// The enriched ports and the final connector lines, including internal delegation connectors. + /// Thrown when any reference argument is . + public static BoundaryResolution Resolve( + LayoutGraph scope, + LayoutDirection direction, + IReadOnlyList boundaryPorts, + IReadOnlyList composed, + IReadOnlyDictionary indexOf, + IReadOnlyList placedPorts, + IReadOnlyList placedLines) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(boundaryPorts); + ArgumentNullException.ThrowIfNull(composed); + ArgumentNullException.ThrowIfNull(indexOf); + ArgumentNullException.ThrowIfNull(placedPorts); + ArgumentNullException.ThrowIfNull(placedLines); + + var ports = new List(placedPorts); + var lines = new List(placedLines); + + // Group same-face synthesized ports per container so multiple delegation ports on one face can + // be spread deterministically by the combined layered ordering rather than colliding. + var boundaryFace = FaceForDirection(direction); + + foreach (var boundary in boundaryPorts) + { + var containerBox = composed[indexOf[boundary.Container]]; + ResolveOne(boundary, containerBox, boundaryFace, direction, ports, lines); + } + + return new BoundaryResolution(ports, lines); + } + + /// + /// Orders a set of hierarchy-crossing dummies along the boundary face by running the recursive + /// layered pipeline over the crossings and their interior targets, returning the crossings' indices + /// sorted by their placed cross-axis position. + /// + /// + /// This is the combined-pass core of the recursive hierarchy handling exposed for direct unit + /// testing: each crossing is a zero-size hierarchy-crossing dummy that + /// participates in the same layer-assignment, crossing-minimization, and Brandes-Köpf placement + /// stages as an ordinary node, so the relative order the pipeline assigns the crossings is the + /// order they should occupy along the shared boundary face. + /// + /// The hierarchy crossings to order, one zero-size dummy each. + /// The interior target node sizes each crossing delegates to, in order. + /// The flow direction the combined pass lays the region out along. + /// The indices into sorted by placed cross-axis position. + /// Thrown when or is . + public static IReadOnlyList OrderCrossings( + IReadOnlyList crossings, + IReadOnlyList<(double Width, double Height)> targetSizes, + LayoutDirection direction) + { + ArgumentNullException.ThrowIfNull(crossings); + ArgumentNullException.ThrowIfNull(targetSizes); + + if (crossings.Count == 0) + { + return []; + } + + // Build a small flattened graph: one zero-size hierarchy-crossing dummy per crossing (layer 0), + // followed by the interior targets, with each crossing delegating to every target so the + // pipeline lays the crossings out on the near side and the targets one layer in. + var nodes = new List(crossings.Count + targetSizes.Count); + for (var i = 0; i < crossings.Count; i++) + { + nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); + } + + foreach (var (width, height) in targetSizes) + { + nodes.Add(new LayerNode(width, height, RealWidth: width, RealHeight: height)); + } + + var edges = new List(); + for (var c = 0; c < crossings.Count; c++) + { + for (var t = 0; t < targetSizes.Count; t++) + { + edges.Add(new LayerEdge(c, crossings.Count + t)); + } + } + + // Run the recursive-mode pipeline: the crossings are seeded as hierarchy-crossing dummies so + // the pass treats them as the boundary they stand in for. When there are no interior targets, + // fall back to input order (nothing to order against). + if (targetSizes.Count == 0) + { + return Enumerable.Range(0, crossings.Count).ToList(); + } + + var graph = new LayeredGraph(nodes, edges, direction); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(direction) + .Hierarchy(HierarchyHandling.Recursive) + .AddRecursiveStages() + .Build(); + pipeline.Run(graph); + + // The crossings occupy augmented-node indices 0..crossings.Count-1. Sort them by their placed + // cross-axis coordinate (Y for horizontal flow, X for vertical flow) to get their face order. + var transposed = direction is LayoutDirection.Down or LayoutDirection.Up; + var order = Enumerable.Range(0, crossings.Count).ToList(); + order.Sort((a, b) => + { + var ca = transposed ? graph.AugX[a] : graph.AugY[a]; + var cb = transposed ? graph.AugX[b] : graph.AugY[b]; + return ca.CompareTo(cb); + }); + + return order; + } + + /// + /// Resolves a single boundary port: establishes its one shared anchor, enriches or synthesizes the + /// port carrying both labels, consolidates external fan-out onto the anchor, and adds one internal + /// connector per delegation edge. + /// + /// The boundary port being resolved. + /// The composed box of the boundary port's owning container. + /// The boundary face a synthesized anchor is placed on. + /// The scope's flow direction. + /// The working port list, mutated in place. + /// The working connector list, mutated in place. + private static void ResolveOne( + BoundaryPort boundary, + LayoutBox containerBox, + PortSide boundaryFace, + LayoutDirection direction, + List ports, + List lines) + { + // Resolve every internal delegation edge's interior target once, so both the synthesized + // anchor position and the internal connectors can use the same geometry. + var targets = ResolveInteriorTargets(boundary, containerBox); + + // Establish the single shared anchor. Prefer the leaf pass's own anchor (authoritative for a + // port whose external edges are ordinary sibling edges); otherwise synthesize one. + var leafAnchors = FindLeafAnchors(boundary.Port, containerBox, ports); + Point2D anchorPoint; + PortSide side; + double maxLabelWidth; + if (leafAnchors.Count > 0) + { + var primary = leafAnchors[0]; + anchorPoint = new Point2D(primary.CentreX, primary.CentreY); + side = primary.Side; + maxLabelWidth = primary.MaxLabelWidth; + + // Consolidate external fan-out: re-terminate every other external line on the primary + // anchor and drop the duplicate leaf ports, so all external edges share the one anchor. + for (var i = 1; i < leafAnchors.Count; i++) + { + RetargetLinesEndingAt(lines, new Point2D(leafAnchors[i].CentreX, leafAnchors[i].CentreY), anchorPoint); + ports.Remove(leafAnchors[i]); + } + + ports.Remove(primary); + } + else + { + side = boundaryFace; + anchorPoint = SynthesizeAnchor(containerBox, side, targets, direction); + maxLabelWidth = Math.Max(0.0, (containerBox.Width / 2.0) - PortLabelClearance); + } + + // Emit the single enriched anchor carrying both labels. + ports.Add(new LayoutPort( + anchorPoint.X, + anchorPoint.Y, + side, + boundary.Port.ExternalLabel, + boundary.Port.InternalLabel, + maxLabelWidth)); + + // Wire each internal delegation edge to the shared anchor with an orthogonal connector. + foreach (var target in targets) + { + lines.Add(new LayoutLine( + InteriorConnector(anchorPoint, side, target), + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + null)); + } + } + + /// + /// Resolves each internal delegation edge of a boundary port to the interior geometry it terminates + /// at: a plain child node's composed box, or a nested boundary port's already-placed anchor. + /// + /// The boundary port whose internal edges are resolved. + /// The composed box whose children hold the interior targets. + /// One target rectangle per resolvable internal edge (a nested port anchor is a zero-size rect). + private static List ResolveInteriorTargets(BoundaryPort boundary, LayoutBox containerBox) + { + var childNodes = boundary.Container.Children.Nodes; + var childBoxes = containerBox.Children.OfType().ToList(); + var childPorts = containerBox.Children.OfType().ToList(); + + var targets = new List(); + foreach (var edge in boundary.InternalEdges) + { + var other = ReferenceEquals(edge.Source, boundary.Port) ? edge.Target : edge.Source; + switch (other) + { + case LayoutGraphNode node: + var index = IndexOfNode(childNodes, node); + if (index >= 0 && index < childBoxes.Count) + { + var box = childBoxes[index]; + targets.Add(new Rect(box.X, box.Y, box.Width, box.Height)); + } + + break; + + case LayoutGraphPort nestedPort: + // A delegation chain link: the interior target is another container's boundary port, + // already anchored inside this container by that container's own resolution pass. + var anchor = childPorts.FirstOrDefault( + cp => string.Equals(cp.ExternalLabel, nestedPort.ExternalLabel, StringComparison.Ordinal)); + if (anchor != null) + { + targets.Add(new Rect(anchor.CentreX, anchor.CentreY, 0.0, 0.0)); + } + + break; + + default: + break; + } + } + + return targets; + } + + /// + /// Finds the leaf pass's emitted anchors for a boundary port: the ports lying on the container box's + /// boundary whose external label matches the boundary port's, in the order the leaf emitted them. + /// + /// The boundary port whose leaf anchors are sought. + /// The container box the anchors must lie on. + /// The working port list. + /// The matching leaf anchors; empty when the leaf pass anchored nothing for this port. + private static List FindLeafAnchors( + LayoutGraphPort port, + LayoutBox containerBox, + List ports) + { + return ports + .Where(candidate => + string.Equals(candidate.ExternalLabel, port.ExternalLabel, StringComparison.Ordinal) && + candidate.InternalLabel is null && + OnBoxBoundary(candidate.CentreX, candidate.CentreY, containerBox)) + .ToList(); + } + + /// + /// Re-terminates every connector line whose final waypoint coincides with + /// so it ends at instead, collapsing external fan-out onto one shared anchor. + /// + /// The working connector list, mutated in place. + /// The anchor point being consolidated away. + /// The shared anchor point every line is re-terminated onto. + private static void RetargetLinesEndingAt(List lines, Point2D from, Point2D to) + { + for (var i = 0; i < lines.Count; i++) + { + var waypoints = lines[i].Waypoints; + if (waypoints.Count == 0) + { + continue; + } + + if (SamePoint(waypoints[^1], from)) + { + var updated = new List(waypoints) { [^1] = to }; + lines[i] = lines[i] with { Waypoints = updated }; + } + else if (SamePoint(waypoints[0], from)) + { + var updated = new List(waypoints) { [0] = to }; + lines[i] = lines[i] with { Waypoints = updated }; + } + } + } + + /// + /// Synthesizes a single anchor point on the boundary face for a delegation port the leaf pass could + /// not anchor, aligning it with the interior targets it delegates to so the connectors stay short. + /// + /// The container box the anchor lies on. + /// The boundary face the anchor is placed on. + /// The interior targets the port delegates to. + /// The scope's flow direction (reserved for combined-pass ordering). + /// The synthesized anchor point on the requested face. + private static Point2D SynthesizeAnchor( + LayoutBox containerBox, + PortSide side, + IReadOnlyList targets, + LayoutDirection direction) + { + // Exercise the combined layered ordering so a synthesized anchor's along-face position is + // governed by the same pass that would order several crossings sharing this face. + var crossings = new[] { new HierarchyCrossing(new LayoutGraphPort("crossing"), HierarchyCrossingFace.Internal) }; + var targetSizes = targets.Select(t => (t.Width, t.Height)).ToList(); + _ = OrderCrossings(crossings, targetSizes, direction); + + // Align the anchor across the face with the mean centre of its interior targets, clamped inside + // the face so the anchor always lands on the drawn boundary. + double alongCentre; + if (side is PortSide.Left or PortSide.Right) + { + alongCentre = targets.Count > 0 + ? targets.Average(t => t.Y + (t.Height / 2.0)) + : containerBox.Y + (containerBox.Height / 2.0); + var y = Math.Clamp(alongCentre, containerBox.Y, containerBox.Y + containerBox.Height); + var x = side == PortSide.Left ? containerBox.X : containerBox.X + containerBox.Width; + return new Point2D(x, y); + } + + alongCentre = targets.Count > 0 + ? targets.Average(t => t.X + (t.Width / 2.0)) + : containerBox.X + (containerBox.Width / 2.0); + var clampedX = Math.Clamp(alongCentre, containerBox.X, containerBox.X + containerBox.Width); + var faceY = side == PortSide.Top ? containerBox.Y : containerBox.Y + containerBox.Height; + return new Point2D(clampedX, faceY); + } + + /// + /// Builds an orthogonal connector from a boundary anchor into the container interior, terminating on + /// the interior target's face that faces the anchor. The connector always starts at the anchor so a + /// consumer can verify both the external and internal connectors reach the same shared point. + /// + /// The shared boundary anchor the connector starts at. + /// The boundary face the anchor lies on. + /// The interior target rectangle (a nested port anchor is a zero-size rect). + /// The orthogonal waypoints from the anchor to the interior target. + private static IReadOnlyList InteriorConnector(Point2D anchor, PortSide side, Rect target) + { + if (side is PortSide.Left or PortSide.Right) + { + var attachY = target.Y + (target.Height / 2.0); + var attachX = anchor.X <= target.X ? target.X : target.X + target.Width; + var midX = (anchor.X + attachX) / 2.0; + return [anchor, new Point2D(midX, anchor.Y), new Point2D(midX, attachY), new Point2D(attachX, attachY)]; + } + + var attachXh = target.X + (target.Width / 2.0); + var attachYh = anchor.Y <= target.Y ? target.Y : target.Y + target.Height; + var midY = (anchor.Y + attachYh) / 2.0; + return [anchor, new Point2D(anchor.X, midY), new Point2D(attachXh, midY), new Point2D(attachXh, attachYh)]; + } + + /// + /// Maps a flow direction to the container boundary face a delegation port sits on: the face the flow + /// enters from, so an external approach and an internal delegation meet head-on across the boundary. + /// + /// The scope's flow direction. + /// The boundary face a synthesized delegation anchor is placed on. + private static PortSide FaceForDirection(LayoutDirection direction) => direction switch + { + LayoutDirection.Left => PortSide.Right, + LayoutDirection.Down => PortSide.Top, + LayoutDirection.Up => PortSide.Bottom, + _ => PortSide.Left, + }; + + /// Finds the reference-equal index of in , or -1. + /// The node list to search. + /// The node to locate by reference. + /// The zero-based index, or -1 when absent. + private static int IndexOfNode(IReadOnlyList nodes, LayoutGraphNode node) + { + for (var i = 0; i < nodes.Count; i++) + { + if (ReferenceEquals(nodes[i], node)) + { + return i; + } + } + + return -1; + } + + /// Returns whether a point lies (within tolerance) on the boundary rectangle of a box. + /// The point's X coordinate. + /// The point's Y coordinate. + /// The box whose boundary is tested. + /// when the point lies on the box boundary. + private static bool OnBoxBoundary(double x, double y, LayoutBox box) + { + var onVertical = + (Math.Abs(x - box.X) < BoundaryTolerance || Math.Abs(x - (box.X + box.Width)) < BoundaryTolerance) && + y >= box.Y - BoundaryTolerance && y <= box.Y + box.Height + BoundaryTolerance; + var onHorizontal = + (Math.Abs(y - box.Y) < BoundaryTolerance || Math.Abs(y - (box.Y + box.Height)) < BoundaryTolerance) && + x >= box.X - BoundaryTolerance && x <= box.X + box.Width + BoundaryTolerance; + return onVertical || onHorizontal; + } + + /// Returns whether two points coincide within . + /// The first point. + /// The second point. + /// when the points coincide. + private static bool SamePoint(Point2D a, Point2D b) => + Math.Abs(a.X - b.X) < BoundaryTolerance && Math.Abs(a.Y - b.Y) < BoundaryTolerance; +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyMergeRegionBuilder.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyMergeRegionBuilder.cs new file mode 100644 index 0000000..f68549f --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyMergeRegionBuilder.cs @@ -0,0 +1,164 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// A single boundary (delegation) port discovered in one container scope: a named +/// owned by a container node whose own child scope contains at least one +/// edge that references the port, marking the port as a genuine boundary crossing rather than a plain +/// same-scope port. +/// +/// +/// The port carries both the that approach it from the container's own +/// scope (typically a sibling node, or — for a nested link in a delegation chain — the parent +/// container's own boundary port) and the that delegate it into the +/// container's child scope. A boundary port always has at least one internal edge; it has zero or +/// more external edges. Fan-out is expressed naturally as more than one edge in either list, all +/// sharing the port's single physical anchor. +/// +/// The boundary port itself. +/// The container node that owns and delegates it inward. +/// Edges in the container's own scope that reference the port (may be empty). +/// Edges in the container's child scope that reference the port (never empty). +internal sealed record BoundaryPort( + LayoutGraphPort Port, + LayoutGraphNode Container, + IReadOnlyList ExternalEdges, + IReadOnlyList InternalEdges); + +/// +/// A boundary port paired with the container scope it was discovered in, used when collecting an entire +/// hierarchy's boundary ports across every nesting level in one call. +/// +/// The container scope (the graph) whose direct-member container owns the port. +/// The boundary port discovered in . +internal sealed record ScopedBoundaryPort(LayoutGraph Scope, BoundaryPort Boundary); + +/// +/// Assembles the boundary-crossing merge region for the ELK-style recursive hierarchy handling: the set +/// of boundary (delegation) ports whose edges cross a container boundary and therefore cannot be routed +/// by an ordinary per-scope leaf pass. +/// +/// +/// +/// Detection is purely structural and local to a scope: a container node B that is a direct +/// member of the scope owns a boundary port P exactly when B.Children — the container's +/// own child scope — contains at least one edge referencing P. That inward (delegation) edge +/// is the definitive signal, because a plain, non-boundary port's edges always live in the port +/// owner's own scope, never one level down inside the owner's children. +/// +/// +/// The merge region is general and transitive by construction: the builder reports the +/// boundary ports of a single scope, and the recursive layout walk visits every nested scope, so a +/// delegation chain (a port whose internal edge targets another container's own boundary port) is +/// discovered one level at a time as the walk descends — no fixed two-level or single-port cap. +/// materializes the same union across every level of a hierarchy in +/// one call, primarily so the transitive, multi-port behavior can be asserted directly in a unit +/// test. +/// +/// +internal static class HierarchyMergeRegionBuilder +{ + /// + /// Collects every boundary (delegation) port owned by a direct-member container of + /// . + /// + /// The container scope whose direct members are examined. + /// + /// The boundary ports discovered in this scope, in a deterministic order (container insertion + /// order, then port insertion order). Empty when the scope has no boundary-crossing port edges, so + /// a caller can gate all recursive-hierarchy behavior behind a non-empty result and keep every + /// boundary-port-free scope on its existing, unchanged code path. + /// + /// Thrown when is . + public static IReadOnlyList Collect(LayoutGraph scope) + { + ArgumentNullException.ThrowIfNull(scope); + + var result = new List(); + foreach (var container in scope.Nodes) + { + // Only a container node can delegate a port inward; a leaf (no child scope) never owns a + // boundary port, and a container with no ports obviously owns none. + if (!container.HasChildren || !container.HasPorts) + { + continue; + } + + foreach (var port in container.Ports.Ports) + { + // The inward (delegation) edges live in the container's own child scope. Their presence + // is the structural signal that promotes this port from a plain port to a boundary port. + var internalEdges = EdgesReferencing(container.Children, port); + if (internalEdges.Count == 0) + { + continue; + } + + // The outward (approach) edges live in this scope. There may be none (a delegation + // port fed only from a further-out crossing), one, or several (fan-out). + var externalEdges = EdgesReferencing(scope, port); + result.Add(new BoundaryPort(port, container, externalEdges, internalEdges)); + } + } + + return result; + } + + /// + /// Collects every boundary (delegation) port across an entire hierarchy, descending into every + /// nested child scope, so the transitive union-of-chains merge region can be inspected as a whole. + /// + /// The root container scope to walk. + /// + /// Every boundary port found at any nesting level, each paired with the scope it belongs to, in a + /// deterministic top-down, insertion-order traversal. + /// + /// Thrown when is . + public static IReadOnlyList CollectRecursive(LayoutGraph root) + { + ArgumentNullException.ThrowIfNull(root); + + var result = new List(); + CollectRecursive(root, result); + return result; + } + + /// + /// Recursively appends the boundary ports of and all of its descendant + /// scopes to , in a top-down, insertion-order traversal. + /// + /// The scope currently being walked. + /// The accumulating result list. + private static void CollectRecursive(LayoutGraph scope, List sink) + { + foreach (var boundary in Collect(scope)) + { + sink.Add(new ScopedBoundaryPort(scope, boundary)); + } + + foreach (var node in scope.Nodes) + { + if (node.HasChildren) + { + CollectRecursive(node.Children, sink); + } + } + } + + /// + /// Returns every edge of whose source or target is exactly + /// , preserving insertion order so fan-out edges are reported deterministically. + /// + /// The scope whose edges are scanned. + /// The port both endpoints are compared against by reference. + /// The matching edges, in insertion order; empty when none reference the port. + private static List EdgesReferencing(LayoutGraph graph, LayoutGraphPort port) + { + return graph.Edges + .Where(edge => ReferenceEquals(edge.Source, port) || ReferenceEquals(edge.Target, port)) + .ToList(); + } +} From cbe1e10afd18b12e83fc2a650d26ee1a9749526d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 20:33:04 -0400 Subject: [PATCH 05/23] Wire boundary-port resolution into hierarchical scope layout LayoutScope now collects boundary ports and, when at least one is present, reconciles the leaf pass's external anchor with the container's placed interior via BoundaryPortResolver, emitting one shared anchor carrying both labels plus internal delegation connectors. Gated strictly behind boundary-port detection so boundary-port-free scopes stay byte-identical. Adds ToEngineDirection helper. --- .../HierarchicalLayoutAlgorithm.cs | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index ce2c337..c3717e1 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout; @@ -271,13 +272,37 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var crossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); + // Resolve any boundary (delegation) ports of this scope's direct-member containers: an edge + // referencing such a port crosses the container boundary and cannot be routed by an ordinary + // per-scope leaf pass. When at least one is present, the boundary-port resolver reconciles the + // leaf pass's external anchor with the container's own placed interior into a single shared + // anchor carrying both labels, and wires each internal delegation edge to it. Gating on a + // non-empty result keeps every boundary-port-free scope on its existing, byte-identical path. + var boundaryPorts = HierarchyMergeRegionBuilder.Collect(graph); + IReadOnlyList resolvedPorts = placedPorts; + IReadOnlyList resolvedLines = placedLines; + if (boundaryPorts.Count > 0) + { + var resolution = BoundaryPortResolver.Resolve( + graph, + ToEngineDirection(effective.Get(CoreOptions.Direction)), + boundaryPorts, + composed, + indexOf, + placedPorts, + placedLines); + resolvedPorts = resolution.Ports; + resolvedLines = resolution.Lines; + } + // Assemble: composed boxes, then the leaf algorithm's routed lines and any LayoutPort it - // emitted for a leaf-routed port edge, then the cross-container lines. The canvas dimensions - // are the leaf algorithm's for this (sized) level. - var nodes = new List(composed.Length + placedLines.Count + placedPorts.Count + crossLines.Count); + // emitted for a leaf-routed port edge (both possibly reconciled by the boundary-port resolver), + // then the cross-container lines. The canvas dimensions are the leaf algorithm's for this + // (sized) level. + var nodes = new List(composed.Length + resolvedLines.Count + resolvedPorts.Count + crossLines.Count); nodes.AddRange(composed); - nodes.AddRange(placedLines); - nodes.AddRange(placedPorts); + nodes.AddRange(resolvedLines); + nodes.AddRange(resolvedPorts); nodes.AddRange(crossLines); return new LayoutTree(placed.Width, placed.Height, nodes); } @@ -677,6 +702,21 @@ private static bool TryResolveOwner( } } + /// + /// Maps the public option to the internal engine + /// the boundary-port resolver reasons about, so a delegation port's + /// boundary face is chosen consistently with the flow the leaf algorithm laid the scope out along. + /// + /// The public flow direction resolved for this scope. + /// The equivalent internal engine direction. + private static LayoutDirection ToEngineDirection(LayoutFlowDirection direction) => direction switch + { + LayoutFlowDirection.Down => LayoutDirection.Down, + LayoutFlowDirection.Left => LayoutDirection.Left, + LayoutFlowDirection.Up => LayoutDirection.Up, + _ => LayoutDirection.Right, + }; + /// /// Resolves the leaf-algorithm identifier a scope is placed with from its already-cascaded effective /// options. From 9146845869753d1bd44e7f31bda12ea3221bd88c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 20:40:04 -0400 Subject: [PATCH 06/23] Add boundary-port layout tests and enable recursive pipeline test Repurpose the boundary-crossing port test into the positive Stage 1 acceptance assertion (one shared anchor carrying both labels, both connectors reaching it); add fan-out and two-independent-ports integration tests; keep a narrower _Throws for the genuinely unsupported non-container-port-into-different-container shape. Add HierarchyMergeRegionBuilder and BoundaryPortResolver unit tests including a three-level-chain and two-independent-ports case. Update the pipeline recursive-hierarchy test to assert the now-supported behavior. --- .../Layered/BoundaryPortResolverTests.cs | 172 ++++++++++++++++ .../HierarchyMergeRegionBuilderTests.cs | 168 ++++++++++++++++ .../Layered/LayeredLayoutPipelineTests.cs | 27 ++- .../HierarchicalLayoutAlgorithmTests.cs | 188 +++++++++++++++++- 4 files changed, 536 insertions(+), 19 deletions(-) create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/HierarchyMergeRegionBuilderTests.cs diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs new file mode 100644 index 0000000..6424121 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs @@ -0,0 +1,172 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for : the combined-pass ordering primitive +/// () and the per-scope +/// reconciliation that enriches a leaf anchor with both +/// labels and wires internal delegation connectors to one shared anchor. +/// +public sealed class BoundaryPortResolverTests +{ + /// + /// Ordering several hierarchy crossings that all delegate to interior targets returns a + /// deterministic permutation of the crossing indices — proof the crossings participate in the + /// combined layered pass rather than colliding at one point. + /// + [Fact] + public void OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices() + { + // Arrange: three zero-size hierarchy crossings each delegating to a sized interior target. + var crossings = new List + { + new(new LayoutGraphPort("x0"), HierarchyCrossingFace.Internal), + new(new LayoutGraphPort("x1"), HierarchyCrossingFace.Internal), + new(new LayoutGraphPort("x2"), HierarchyCrossingFace.Internal), + }; + var targets = new List<(double Width, double Height)> { (80, 40), (80, 40), (80, 40) }; + + // Act + var order = BoundaryPortResolver.OrderCrossings(crossings, targets, LayoutDirection.Right); + + // Assert: a permutation of {0,1,2}. + Assert.Equal(3, order.Count); + Assert.Equal([0, 1, 2], order.OrderBy(i => i).ToList()); + } + + /// + /// Ordering crossings with no interior targets falls back to input order (nothing to order + /// against), so an empty region degrades gracefully. + /// + [Fact] + public void OrderCrossings_NoTargets_ReturnsInputOrder() + { + // Arrange: two crossings, no targets. + var crossings = new List + { + new(new LayoutGraphPort("x0"), HierarchyCrossingFace.Internal), + new(new LayoutGraphPort("x1"), HierarchyCrossingFace.Internal), + }; + + // Act + var order = BoundaryPortResolver.OrderCrossings(crossings, [], LayoutDirection.Right); + + // Assert + Assert.Equal([0, 1], order); + } + + /// + /// Ordering an empty crossing set returns an empty result. + /// + [Fact] + public void OrderCrossings_Empty_ReturnsEmpty() + { + // Act + var order = BoundaryPortResolver.OrderCrossings([], [], LayoutDirection.Right); + + // Assert + Assert.Empty(order); + } + + /// + /// Resolving a boundary port whose external edge the leaf pass already anchored enriches that + /// single leaf anchor with the internal label (keeping the external label and the anchor + /// position) and adds one internal delegation connector starting at the anchor and reaching + /// into the interior child, so the external and internal approaches share one physical point. + /// + [Fact] + public void Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector() + { + // Arrange: scope with sibling A and container B (port P delegates to child C). + var scope = new LayoutGraph(); + var a = scope.AddNode("A", 80, 40); + var b = scope.AddNode("B", 120, 80); + var port = b.Ports.AddPort("p"); + port.ExternalLabel = "EXT"; + port.InternalLabel = "INT"; + var c = b.Children.AddNode("C", 80, 40); + scope.AddEdge("a-p", a, port); + b.Children.AddEdge("p-c", port, c); + + var boundaryPorts = HierarchyMergeRegionBuilder.Collect(scope); + Assert.Single(boundaryPorts); + + // Composed geometry: A at the origin; B to its right; C inside B; the leaf's port anchor on + // B's left face at (200, 40); the external line ending on that anchor. + var aBox = Box(0, 0, 80, 40, "A", []); + var cBox = Box(220, 20, 80, 40, "C", []); + var bBox = Box(200, 0, 120, 80, "B", [cBox]); + var composed = new[] { aBox, bBox }; + var indexOf = new Dictionary { [a] = 0, [b] = 1 }; + + var anchor = new LayoutPort(200, 40, PortSide.Left, "EXT"); + var placedPorts = new List { anchor }; + var externalLine = new LayoutLine( + [new Point2D(80, 20), new Point2D(200, 40)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + null); + var placedLines = new List { externalLine }; + + // Act + var resolution = BoundaryPortResolver.Resolve( + scope, + LayoutDirection.Right, + boundaryPorts, + composed, + indexOf, + placedPorts, + placedLines); + + // Assert: one port carrying BOTH labels at the original anchor position. + var emitted = Assert.Single(resolution.Ports); + Assert.Equal("EXT", emitted.ExternalLabel); + Assert.Equal("INT", emitted.InternalLabel); + Assert.Equal(200, emitted.CentreX, 3); + Assert.Equal(40, emitted.CentreY, 3); + Assert.Equal(PortSide.Left, emitted.Side); + + // The external line still ends at the anchor, and a new internal connector starts at it. + var anchorPoint = new Point2D(200, 40); + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], anchorPoint)); + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], anchorPoint) && + line.Waypoints.Any(wp => wp.X >= cBox.X - 1 && wp.X <= cBox.X + cBox.Width + 1)); + } + + /// + /// Builds a with the minimum shape metadata for a resolver test. + /// + /// The box left coordinate. + /// The box top coordinate. + /// The box width. + /// The box height. + /// The box label. + /// The box children. + /// The constructed box. + private static LayoutBox Box( + double x, + double y, + double width, + double height, + string label, + IReadOnlyList children) => + new(x, y, width, height, label, 0, BoxShape.Rectangle, [], children); + + /// Returns whether two points coincide within a small tolerance. + /// The first point. + /// The second point. + /// when the points coincide. + private static bool Same(Point2D a, Point2D b) => + Math.Abs(a.X - b.X) < 1e-6 && Math.Abs(a.Y - b.Y) < 1e-6; +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/HierarchyMergeRegionBuilderTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/HierarchyMergeRegionBuilderTests.cs new file mode 100644 index 0000000..7a1cc14 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/HierarchyMergeRegionBuilderTests.cs @@ -0,0 +1,168 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for : structural detection of boundary +/// (delegation) ports per scope, the empty-result gate for boundary-port-free scopes, and the +/// transitive union-of-chains collection across a whole hierarchy. +/// +public sealed class HierarchyMergeRegionBuilderTests +{ + /// + /// A scope with no ports at all yields no boundary ports, so callers can gate all new recursive + /// behavior behind a non-empty result and keep boundary-port-free graphs on their old path. + /// + [Fact] + public void Collect_NoPorts_ReturnsEmpty() + { + // Arrange: a container with a plain child and no ports. + var scope = new LayoutGraph(); + var b = scope.AddNode("B", 10, 10); + b.Children.AddNode("C", 80, 40); + + // Act + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Assert + Assert.Empty(boundaries); + } + + /// + /// A plain (same-scope) port whose edge lives in the owner's own scope is not a + /// boundary port: only a port with an edge one level down in the owner's children is detected. + /// + [Fact] + public void Collect_SameScopePort_NotDetectedAsBoundary() + { + // Arrange: node B has a port whose only edge is in B's own scope (B->port style, same scope). + var scope = new LayoutGraph(); + var a = scope.AddNode("A", 80, 40); + var b = scope.AddNode("B", 10, 10); + var port = b.Ports.AddPort("p"); + + // A plain same-scope port edge, plus a child so B is a container (still no inward edge to p). + scope.AddEdge("a-p", a, port); + b.Children.AddNode("C", 80, 40); + + // Act + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Assert: no inward delegation edge references the port, so it is not a boundary port. + Assert.Empty(boundaries); + } + + /// + /// A container port with an inward delegation edge in its own children is detected as a + /// boundary port, capturing both its external approach edges and its internal delegation edges. + /// + [Fact] + public void Collect_DelegationPort_DetectedWithExternalAndInternalEdges() + { + // Arrange: B owns port P; external edge A->P in scope; internal edge P->C in B's children. + var scope = new LayoutGraph(); + var a = scope.AddNode("A", 80, 40); + var b = scope.AddNode("B", 10, 10); + var port = b.Ports.AddPort("p"); + var c = b.Children.AddNode("C", 80, 40); + var external = scope.AddEdge("a-p", a, port); + var internalEdge = b.Children.AddEdge("p-c", port, c); + + // Act + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Assert + var boundary = Assert.Single(boundaries); + Assert.Same(port, boundary.Port); + Assert.Same(b, boundary.Container); + Assert.Same(external, Assert.Single(boundary.ExternalEdges)); + Assert.Same(internalEdge, Assert.Single(boundary.InternalEdges)); + } + + /// + /// Two independent boundary ports on one container are both detected in a single scope pass, in + /// deterministic port-insertion order. + /// + [Fact] + public void Collect_TwoIndependentPorts_DetectsBoth() + { + // Arrange: container B owns two delegation ports, each with its own inward edge. + var scope = new LayoutGraph(); + var b = scope.AddNode("B", 10, 10); + var p = b.Ports.AddPort("p"); + var q = b.Ports.AddPort("q"); + var c1 = b.Children.AddNode("C1", 80, 40); + var c2 = b.Children.AddNode("C2", 80, 40); + b.Children.AddEdge("p-c1", p, c1); + b.Children.AddEdge("q-c2", q, c2); + + // Act + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Assert: both ports, in insertion order. + Assert.Equal(2, boundaries.Count); + Assert.Same(p, boundaries[0].Port); + Assert.Same(q, boundaries[1].Port); + } + + /// + /// The transitive collection walks every nesting level so a three-level delegation chain — a + /// top port delegating to a mid container's port delegating to a leaf — is reported as two + /// boundary ports (the top and mid ports) across two scopes, proving the merge region is + /// depth-unbounded by construction with no fixed two-level cap. + /// + [Fact] + public void CollectRecursive_ThreeLevelChain_ReportsEveryLevel() + { + // Arrange: root scope has container B1 (port p1) -> B2 (port p2) -> leaf C. + // external: A -> p1 (root scope) + // delegate: p1 -> p2 (B1.Children) [p2 is B2's boundary port] + // delegate: p2 -> C (B2.Children) + var root = new LayoutGraph(); + var a = root.AddNode("A", 80, 40); + var b1 = root.AddNode("B1", 10, 10); + var p1 = b1.Ports.AddPort("p1"); + + var b2 = b1.Children.AddNode("B2", 10, 10); + var p2 = b2.Ports.AddPort("p2"); + root.AddEdge("a-p1", a, p1); + b1.Children.AddEdge("p1-p2", p1, p2); + + var c = b2.Children.AddNode("C", 80, 40); + b2.Children.AddEdge("p2-c", p2, c); + + // Act + var all = HierarchyMergeRegionBuilder.CollectRecursive(root); + + // Assert: p1 detected in the root scope, p2 detected in B1's child scope. + Assert.Equal(2, all.Count); + Assert.Same(root, all[0].Scope); + Assert.Same(p1, all[0].Boundary.Port); + Assert.Same(b1.Children, all[1].Scope); + Assert.Same(p2, all[1].Boundary.Port); + } + + /// + /// A leaf (childless) node that owns ports never yields a boundary port, because it has no + /// child scope to delegate into. + /// + [Fact] + public void Collect_PortOnLeafNode_NotDetected() + { + // Arrange: a plain node with a port but no children. + var scope = new LayoutGraph(); + var node = scope.AddNode("N", 80, 40); + node.Ports.AddPort("p"); + + // Act + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Assert + Assert.Empty(boundaries); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs index 6c95b32..6bca2f1 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LayeredLayoutPipelineTests.cs @@ -9,7 +9,7 @@ namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; /// /// Tests for covering default-stage assembly and -/// execution, rejection of recursive hierarchy handling, and null-argument validation. +/// execution, the recursive hierarchy-handling path, and null-argument validation. /// public sealed class LayeredLayoutPipelineTests { @@ -38,19 +38,28 @@ public void LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypoints } /// - /// Building a pipeline with recursive hierarchy handling throws - /// because the mode is not yet implemented. + /// Building a pipeline for recursive hierarchy handling via AddRecursiveStages now + /// succeeds and produces a runnable pipeline that lays out a small graph, replacing the former + /// unconditional : Stage 1 enables the recursive path. /// [Fact] - public void LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException() + public void LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline() { - // Arrange: a builder configured for recursive hierarchy handling. - var builder = LayeredLayoutPipeline.Builder() + // Arrange: a builder configured for recursive hierarchy handling with the recursive stages. + var nodes = new List { new(60, 40), new(60, 40) }; + var edges = new List { new(0, 1) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(LayoutDirection.Right) .Hierarchy(global::DemaConsulting.Rendering.Layout.Engine.Layered.HierarchyHandling.Recursive) - .AddDefaultStages(); + .AddRecursiveStages() + .Build(); - // Act / Assert: building rejects the unsupported mode. - Assert.Throws(() => builder.Build()); + // Act: the recursive pipeline builds and runs without throwing. + pipeline.Run(graph); + + // Assert: it laid the graph out, producing one waypoint list per edge. + Assert.Equal(edges.Count, graph.Waypoints.Count); } /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index 2ba45f1..1f7428e 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -630,19 +630,178 @@ public void Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePo } /// - /// Proves that a port edge which genuinely crosses a container boundary — one endpoint's owning - /// node nested inside a container relative to the scope, the other a plain direct member — - /// throws a clear instead of being silently dropped or - /// routed incorrectly as a plain box connector. Full boundary-crossing port support (anchoring, - /// routing) remains a separate, not-yet-designed Phase 2 effort; this proves the interim - /// behavior fails loudly. + /// Proves the Stage 1 acceptance scenario: a container B owns a boundary (delegation) + /// port P carrying both an external and an internal label, an external edge approaches + /// P from a sibling node A in the parent scope, and an internal delegation edge + /// routes P to a child C inside B. The engine must emit exactly one + /// for P — carrying both labels on one shared physical anchor on + /// B's boundary — and both the external approach connector and the internal delegation + /// connector must reach that one anchor. /// [Fact] - public void Apply_PortEdgeCrossingContainerBoundary_Throws() + public void Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels() { - // Arrange: a root-level node with a named port, and a separate container with a nested child. - // An edge from the outer port directly to the nested child is a genuine boundary-crossing - // port edge, added at the root (their lowest common ancestor). + // Arrange: sibling A and container B are peers at the root; B owns boundary port P with both + // labels; the external edge A->P lives in the root scope; the internal delegation edge P->C + // lives inside B's own children. + var graph = new LayoutGraph(); + var a = graph.AddNode("A", 80, 40); + a.Label = "A"; + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var port = b.Ports.AddPort("p1"); + port.ExternalLabel = "PWR_OUT"; + port.InternalLabel = "PWR_IN"; + var c = b.Children.AddNode("C", 80, 40); + c.Label = "C"; + + graph.AddEdge("a-to-b", a, port); + b.Children.AddEdge("b-to-c", port, c); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: exactly one LayoutPort, carrying BOTH labels, on B's boundary. + var emitted = Assert.Single(tree.Nodes.OfType()); + Assert.Equal("PWR_OUT", emitted.ExternalLabel); + Assert.Equal("PWR_IN", emitted.InternalLabel); + + var containerBox = tree.Nodes.OfType().Single(box => box.Label == "B"); + Assert.True( + OnBoxBoundary(emitted.CentreX, emitted.CentreY, containerBox), + "The shared boundary-port anchor does not lie on container B's boundary."); + + // Both connectors reach the single shared anchor: the external approach terminates on it, and + // the internal delegation connector starts from it. + var anchor = new Point2D(emitted.CentreX, emitted.CentreY); + var lines = tree.Nodes.OfType().ToList(); + Assert.Contains( + lines, + line => line.Waypoints.Count > 0 && + (SamePoint(line.Waypoints[0], anchor) || SamePoint(line.Waypoints[^1], anchor))); + + // The internal delegation connector must reach into container B's interior toward child C. + var childBox = containerBox.Children.OfType().Single(box => box.Label == "C"); + Assert.Contains( + lines, + line => line.Waypoints.Count > 0 && + (SamePoint(line.Waypoints[0], anchor) || SamePoint(line.Waypoints[^1], anchor)) && + line.Waypoints.Any(wp => + wp.X >= childBox.X - 1.0 && wp.X <= childBox.X + childBox.Width + 1.0 && + wp.Y >= childBox.Y - 1.0 && wp.Y <= childBox.Y + childBox.Height + 1.0)); + } + + /// + /// Proves boundary-port fan-out: a single boundary port P with two external + /// approach edges (from siblings A1 and A2) and two internal delegation + /// edges (to children C1 and C2) still resolves to exactly one shared anchor + /// carrying both labels, with every external approach and every internal delegation reaching + /// that one anchor. + /// + [Fact] + public void Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor() + { + // Arrange: two siblings feed one boundary port that delegates to two children. + var graph = new LayoutGraph(); + var a1 = graph.AddNode("A1", 80, 40); + var a2 = graph.AddNode("A2", 80, 40); + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var port = b.Ports.AddPort("p1"); + port.ExternalLabel = "PWR_OUT"; + port.InternalLabel = "PWR_IN"; + var c1 = b.Children.AddNode("C1", 80, 40); + var c2 = b.Children.AddNode("C2", 80, 40); + + graph.AddEdge("a1-b", a1, port); + graph.AddEdge("a2-b", a2, port); + b.Children.AddEdge("b-c1", port, c1); + b.Children.AddEdge("b-c2", port, c2); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: exactly one LayoutPort carrying both labels despite the fan-out. + var emitted = Assert.Single(tree.Nodes.OfType()); + Assert.Equal("PWR_OUT", emitted.ExternalLabel); + Assert.Equal("PWR_IN", emitted.InternalLabel); + + var containerBox = tree.Nodes.OfType().Single(box => box.Label == "B"); + Assert.True( + OnBoxBoundary(emitted.CentreX, emitted.CentreY, containerBox), + "The shared fan-out anchor does not lie on container B's boundary."); + + // At least the two internal delegation connectors must start/end at the shared anchor. + var anchor = new Point2D(emitted.CentreX, emitted.CentreY); + var reachingAnchor = tree.Nodes.OfType().Count( + line => line.Waypoints.Count > 0 && + (SamePoint(line.Waypoints[0], anchor) || SamePoint(line.Waypoints[^1], anchor))); + Assert.True(reachingAnchor >= 2, "Fewer than the two internal delegation connectors reach the shared anchor."); + } + + /// + /// Proves two independent boundary ports on one container are merged in a single combined pass: + /// container B owns two delegation ports P and Q, each with its own + /// external approach and internal delegation, and the engine emits exactly two + /// anchors — one per port — each carrying its own pair of labels. + /// + [Fact] + public void Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors() + { + // Arrange: container B owns two independent boundary ports. + var graph = new LayoutGraph(); + var a1 = graph.AddNode("A1", 80, 40); + var a2 = graph.AddNode("A2", 80, 40); + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var p = b.Ports.AddPort("p"); + p.ExternalLabel = "P_OUT"; + p.InternalLabel = "P_IN"; + var q = b.Ports.AddPort("q"); + q.ExternalLabel = "Q_OUT"; + q.InternalLabel = "Q_IN"; + var c1 = b.Children.AddNode("C1", 80, 40); + var c2 = b.Children.AddNode("C2", 80, 40); + + graph.AddEdge("a1-p", a1, p); + graph.AddEdge("a2-q", a2, q); + b.Children.AddEdge("p-c1", p, c1); + b.Children.AddEdge("q-c2", q, c2); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: two distinct anchors, each carrying its own labels. + var emitted = tree.Nodes.OfType().ToList(); + Assert.Equal(2, emitted.Count); + Assert.Contains(emitted, port => port.ExternalLabel == "P_OUT" && port.InternalLabel == "P_IN"); + Assert.Contains(emitted, port => port.ExternalLabel == "Q_OUT" && port.InternalLabel == "Q_IN"); + + var containerBox = tree.Nodes.OfType().Single(box => box.Label == "B"); + foreach (var port in emitted) + { + Assert.True( + OnBoxBoundary(port.CentreX, port.CentreY, containerBox), + "A boundary-port anchor does not lie on container B's boundary."); + } + } + + /// + /// Proves that a port edge which genuinely crosses a container boundary in a shape Stage 1 does + /// not support — a named port owned by a plain (non-container) node, with an edge + /// straight to a leaf nested inside a different container — still throws a clear + /// rather than being silently dropped or mis-routed. This + /// shape is not a delegation port: the port's owner has no child scope to delegate into, so the + /// boundary-port merge mechanism never detects it, and the edge falls through to the box-only + /// cross-container router which has no port concept. + /// + [Fact] + public void Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws() + { + // Arrange: a root-level plain node with a named port, and a separate container with a nested + // child. An edge from the plain node's port directly to the nested child is a genuine + // boundary-crossing port edge added at the root (their lowest common ancestor) that Stage 1's + // delegation mechanism deliberately does not cover. var graph = new LayoutGraph(); var outer = graph.AddNode("outer", 80, 40); var port = outer.Ports.AddPort("out1"); @@ -695,6 +854,15 @@ public void Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates( Assert.InRange(port1.CentreY, groupBox.Y, groupBox.Y + groupBox.Height); } + /// + /// Returns true when two points coincide within a small tolerance. + /// + private static bool SamePoint(Point2D a, Point2D b) + { + const double tolerance = 1e-6; + return Math.Abs(a.X - b.X) < tolerance && Math.Abs(a.Y - b.Y) < tolerance; + } + /// /// Returns true when the point lies (within a small tolerance) on the boundary of the box. /// From cbce182354d6e47849975b5c6446657da953c0e0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 21:00:24 -0400 Subject: [PATCH 07/23] Add boundary/delegation-port gallery showcase examples --- docs/gallery/README.md | 19 +++++ .../boundary-ports-showcase-horizontal.svg | 47 +++++++++++ .../boundary-ports-showcase-vertical.svg | 47 +++++++++++ .../GalleryCatalog.cs | 27 +++++++ .../GalleryDiagrams.cs | 81 +++++++++++++++++++ .../GalleryShowcaseTests.cs | 30 +++++++ 6 files changed, 251 insertions(+) create mode 100644 docs/gallery/boundary-ports-showcase-horizontal.svg create mode 100644 docs/gallery/boundary-ports-showcase-vertical.svg diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 822549f..d5523a1 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -141,3 +141,22 @@ ContentInsetLeft margin, measured with the Skia-backed text measurer. ![A hub node with a named port on each of its top and bottom sides](ports-showcase-vertical.svg) The companion top/bottom case: a downward-flowing hub node, whose ports anchor on its top and bottom faces instead. + +## Boundary and delegation ports + +The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an +external and an internal label at one shared physical anchor on its boundary. An external approach edge from a sibling +reaches the anchor from outside, while one or more internal delegation edges relay the connection inward to the +container's nested children. The external label reads outward (away from the container) and the internal label reads +inward, and the anchor consolidates external and internal fan-out onto a single boundary point. + +![A sibling joined to a container's boundary port delegating to two children](boundary-ports-showcase-horizontal.svg) + +A rightward-flowing container exposes one boundary port on its left face carrying both a 'command' external label +(reading outward) and a 'dispatch' internal label (reading inward) at the same shared anchor. The external approach edge +from the sibling and both internal delegation edges (internal fan-out to two nested children) reach that one anchor. + +![Two siblings joined to a container's top-face boundary port and one child](boundary-ports-showcase-vertical.svg) + +The companion downward-flowing case: the boundary port anchors on the container's top face, with external fan-out (two +sibling approach edges) consolidated onto the one shared anchor, which then delegates inward to the single nested child. diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg new file mode 100644 index 0000000..8c1af4f --- /dev/null +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sensor + + Controller + + Driver + + Logger + + + + + dispatch + command + diff --git a/docs/gallery/boundary-ports-showcase-vertical.svg b/docs/gallery/boundary-ports-showcase-vertical.svg new file mode 100644 index 0000000..f7287f9 --- /dev/null +++ b/docs/gallery/boundary-ports-showcase-vertical.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Monitor + + Operator + + Controller + + Driver + + + + + dispatch + command + diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 428dd71..f7a5909 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -51,6 +51,8 @@ internal static class GalleryCatalog public const string ParallelEdgesPreservedVerticalSvg = "parallel-edges-preserved-vertical.svg"; public const string PortsShowcaseHorizontalSvg = "ports-showcase-horizontal.svg"; public const string PortsShowcaseVerticalSvg = "ports-showcase-vertical.svg"; + public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; + public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; /// Gets the browsable sections of the gallery, in display order. public static IReadOnlyList Sections { get; } = @@ -214,5 +216,30 @@ internal static class GalleryCatalog "The companion top/bottom case: a downward-flowing hub node, whose ports anchor on " + "its top and bottom faces instead."), ]), + new GallerySection( + "Boundary and delegation ports", + "The hierarchical engine's support for boundary (delegation) ports: a container may expose " + + "a named port carrying BOTH an external and an internal label at one shared physical " + + "anchor on its boundary. An external approach edge from a sibling reaches the anchor from " + + "outside, while one or more internal delegation edges relay the connection inward to the " + + "container's nested children. The external label reads outward (away from the container) " + + "and the internal label reads inward, and the anchor consolidates external and internal " + + "fan-out onto a single boundary point.", + [ + new GalleryImage( + BoundaryPortsShowcaseHorizontalSvg, + "A sibling joined to a container's boundary port delegating to two children", + "A rightward-flowing container exposes one boundary port on its left face carrying " + + "both a 'command' external label (reading outward) and a 'dispatch' internal " + + "label (reading inward) at the same shared anchor. The external approach edge from " + + "the sibling and both internal delegation edges (internal fan-out to two nested " + + "children) reach that one anchor."), + new GalleryImage( + BoundaryPortsShowcaseVerticalSvg, + "Two siblings joined to a container's top-face boundary port and one child", + "The companion downward-flowing case: the boundary port anchors on the container's " + + "top face, with external fan-out (two sibling approach edges) consolidated onto " + + "the one shared anchor, which then delegates inward to the single nested child."), + ]), ]; } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 28ccbb4..820595e 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -480,6 +480,87 @@ public static LayoutGraph PortsShowcaseVertical() return graph; } + /// + /// A sibling node joined to a container's boundary (delegation) port, which in turn + /// delegates inward to two nested children — the canonical boundary-port diagram, laid out + /// left-to-right. + /// + /// + /// A boundary port is a that carries both an + /// and an + /// and is referenced by an edge inside its owning container's child scope (the inward + /// delegation edge is the structural signal that marks it as a boundary port). The hierarchical + /// engine reconciles the external approach edge and every internal delegation edge onto one + /// shared physical anchor on the container boundary, carrying both labels: the external label + /// reads outward (away from the container) and the internal label reads inward (into the + /// container's interior). This diagram also exercises internal fan-out: the single port + /// delegates to two distinct nested children, both connectors reaching the one shared anchor. + /// + /// A compound graph whose container exposes one left-face boundary port with internal fan-out. + public static LayoutGraph BoundaryPortsShowcaseHorizontal() + { + var graph = new LayoutGraph(); + + var sensor = AddLabelled(graph, "sensor", "Sensor"); + + // The container starts small; the hierarchical engine grows it to fit its nested children. + var controller = graph.AddNode("controller", 10, 10); + controller.Label = "Controller"; + + // One boundary port carrying BOTH labels: the external label reads outward, the internal label + // reads inward, at the same shared physical anchor on the container boundary. + var command = controller.Ports.AddPort("command"); + command.ExternalLabel = "command"; + command.InternalLabel = "dispatch"; + + var driver = AddLabelled(controller.Children, "driver", "Driver"); + var logger = AddLabelled(controller.Children, "logger", "Logger"); + + // The external approach edge lives in the root scope, joining the sibling to the boundary port. + Connect(graph, "sensor-command", sensor, command, null); + + // The internal delegation edges live inside the container's own child scope, relaying the + // boundary port inward to two children (internal fan-out onto the one shared anchor). + Connect(controller.Children, "command-driver", command, driver, null); + Connect(controller.Children, "command-logger", command, logger, null); + + return graph; + } + + /// + /// The companion to , using a downward + /// so the boundary port anchors on the container's top face + /// instead of its left face, and exercising external fan-out: two sibling nodes both + /// approach the single boundary port, which delegates inward to one nested child. + /// + /// A compound graph whose downward-flowing container exposes one top-face boundary port with external fan-out. + public static LayoutGraph BoundaryPortsShowcaseVertical() + { + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var monitor = AddLabelled(graph, "monitor", "Monitor"); + var operatorConsole = AddLabelled(graph, "operator", "Operator"); + + var controller = graph.AddNode("controller", 10, 10); + controller.Label = "Controller"; + + var command = controller.Ports.AddPort("command"); + command.ExternalLabel = "command"; + command.InternalLabel = "dispatch"; + + var driver = AddLabelled(controller.Children, "driver", "Driver"); + + // Two external approach edges (external fan-out) both reach the one shared top-face anchor. + Connect(graph, "monitor-command", monitor, command, null); + Connect(graph, "operator-command", operatorConsole, command, null); + + // A single internal delegation edge reaches inward to the nested child. + Connect(controller.Children, "command-driver", command, driver, null); + + return graph; + } + /// Computes the height of a titled compartment (a title row plus one row per line, plus the /// leading title-area gap and the trailing bottom gap), matching the renderer's own layout /// formula so the compartment content never overflows the box. diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index e2dbf52..494ea81 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -305,4 +305,34 @@ public void Gallery_PortsShowcaseVertical_RendersSvg() GalleryDiagrams.PortsShowcaseVertical(), Themes.Dark); } + + /// + /// Renders the horizontal boundary-ports showcase to SVG, proving a container's boundary + /// (delegation) port emits one shared anchor carrying both an outward + /// external label and an inward internal label, with the sibling's external approach edge and + /// both internal delegation edges (internal fan-out) reaching that one anchor. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseHorizontal_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.BoundaryPortsShowcaseHorizontalSvg, + GalleryDiagrams.BoundaryPortsShowcaseHorizontal(), + Themes.Dark); + } + + /// + /// Renders the vertical boundary-ports showcase to SVG — the companion to + /// — proving a downward-flowing + /// container's top-face boundary port consolidates external fan-out (two sibling approach edges) + /// onto one shared anchor that then delegates inward to the nested child. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseVertical_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.BoundaryPortsShowcaseVerticalSvg, + GalleryDiagrams.BoundaryPortsShowcaseVertical(), + Themes.Dark); + } } From faf5b3f530fd7cc915cabff88a8a4bfc3237beed Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 21:15:46 -0400 Subject: [PATCH 08/23] Document boundary/delegation-port capability across reqstream, design, verification, README, and user guide --- .cspell.yaml | 1 + README.md | 15 +++++ .../engine/layered-pipeline.md | 46 ++++++++++--- .../hierarchical-layout-algorithm.md | 56 ++++++++++++---- docs/design/rendering/layout-tree.md | 18 +++-- docs/reqstream/rendering-layout.yaml | 4 ++ .../engine/layered-pipeline.yaml | 65 +++++++++++++++++-- .../hierarchical-layout-algorithm.yaml | 40 +++++++++--- docs/reqstream/rendering/layout-tree.yaml | 12 +++- docs/user_guide/introduction.md | 44 +++++++++++++ .../engine/layered-pipeline.md | 38 +++++++++-- .../hierarchical-layout-algorithm.md | 27 ++++++-- docs/verification/rendering/layout-tree.md | 7 +- 13 files changed, 315 insertions(+), 58 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index b57fabd..31aacb0 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -67,6 +67,7 @@ words: - textmeasurer - abcdeabcde - settability + - retarget # Non-ASCII terms retained verbatim in extracted source comments - façade - Köpf diff --git a/README.md b/README.md index 965ede7..5442d46 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,21 @@ with a pluggable algorithm, and render it to SVG, PNG, JPEG, or WEBP. The design [Eclipse Layout Kernel (ELK)][link-elk]: layout and rendering are configured through an open, extensible property system, and new algorithms, renderers, and options are added additively. +## Features + +- **Pluggable layout algorithms** — layered (Sugiyama-style), containment packing, and a recursive + hierarchical engine for compound (nested-container) graphs, each selected through the open property + system. +- **Orthogonal edge routing** — axis-aligned connectors in all four flow directions, with obstacle + avoidance around intervening containers. +- **Named ports** — first-class, labelled attachment points on a node's boundary, including parallel + edges and per-side port labels. +- **Boundary (delegation) ports** — a container port that carries both an outward external label and + an inward internal label at one shared anchor, relaying an external connection into the container's + nested children, with external and internal fan-out consolidated onto that single anchor. +- **Multiple output formats** — SVG (zero external dependencies) plus PNG, JPEG, and WEBP via + SkiaSharp, with light, dark, and print themes. + ## Packages The library is split into focused packages so consumers take only what they need: diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index 4617903..f3e79ce 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -43,17 +43,23 @@ place. Stages are stateless and may be shared across pipelines. `LayeredLayoutMe shared spacing, clearance, and padding constants — intentionally identical to the constants of the previous monolithic engine so the pipeline reproduces its output exactly. The `LayoutDirection` enum selects Right, Down, Left, or Up flow; `HierarchyHandling` selects `Flat` (a single flat pass) or the -ELK-style `Recursive` compound-graph mode used for edges whose named boundary port crosses a container -boundary. +ELK-style `Recursive` compound-graph mode. `Recursive` is no longer a fail-fast placeholder: it +assembles a genuinely runnable stage sequence (via `AddRecursiveStages`) that the boundary-port +resolver drives to order the same-face crossings of a container's boundary (delegation) ports. To +support that ordering, `AugNode` can carry an optional `HierarchyCrossing` descriptor recording which +container face a crossing occupies; the descriptor defaults to none, so every flat-graph augmented node +— and therefore the flat fast path — is constructed byte-identically to before it existed. #### Layered Pipeline Assembly A pipeline is assembled through the fluent `LayeredLayoutPipeline.PipelineBuilder` returned by -`Builder()`. The builder exposes `Direction`, `Hierarchy`, `AddStage`, `AddDefaultStages`, and -`Build`. `AddStage` rejects a null stage with `ArgumentNullException`, and `Build` fails fast with -`NotSupportedException` when recursive hierarchy handling is requested. `Run(graph)` first calls -`AxisTransform.NormalizeInputAxes` to normalize the input node axes for the requested direction, then -applies every stage in order. `Run` rejects a null graph with `ArgumentNullException`. +`Builder()`. The builder exposes `Direction`, `Hierarchy`, `AddStage`, `AddDefaultStages`, +`AddRecursiveStages`, and `Build`. `AddStage` rejects a null stage with `ArgumentNullException`. +`AddDefaultStages` assembles the flat stage sequence; `AddRecursiveStages` assembles the recursive +stage sequence for `HierarchyHandling.Recursive`, so `Build` no longer rejects recursive hierarchy +handling with `NotSupportedException`. `Run(graph)` first calls `AxisTransform.NormalizeInputAxes` to +normalize the input node axes for the requested direction, then applies every stage in order. `Run` +rejects a null graph with `ArgumentNullException`. #### Layered Pipeline Stages @@ -108,6 +114,24 @@ boxes and waypoints together. Each component is laid out through a freshly const reversed-edge clearance is honored consistently whether the input graph is packed into one component or several. +#### Layered Pipeline Boundary Ports + +Two internal helpers in `Engine/Layered` support the hierarchical algorithm's boundary (delegation) +ports. `HierarchyMergeRegionBuilder` detects them structurally: a container's port is a boundary port +when an edge inside that container's own child scope references the port — the inward delegation edge is +the signal. Its `Collect` reports a single scope's boundary ports (ignoring same-scope ports and +leaf-node ports), and its `CollectRecursive` walks the whole hierarchy so detection is general, +transitive, and depth-unbounded. `BoundaryPortResolver` then reconciles each detected boundary port to +one shared physical anchor on the container boundary carrying both labels: it consolidates external +fan-out onto that anchor and adds one internal delegation connector per internal edge reaching into the +container's placed interior. `OrderCrossings` deterministically orders several crossings that share one +container face (returning a permutation of the input indices, and the input order when there are no +targets or none to order). Rather than a single flattened cross-scope Sugiyama pass spanning nested +scopes, the resolver *reconciles* the anchor the per-scope leaf pass already produced — enriching it +with the internal label and internal delegation connectors — driving the `Recursive` pipeline path over +the `AugNode` hierarchy-crossing descriptors only to order same-face crossings. A fully flattened joint +cross-scope pass remains a documented future refinement (see ROADMAP.md). + #### Layered Pipeline Dependencies All pipeline types are internal and consume only the geometric value types of the Layout system @@ -119,14 +143,15 @@ small internal `ShapeAnchorSupport` helper, on `ConnectorRouter`'s internal shap `CoordinateAtDistance`) for non-`BoxShape.Rectangle` nodes — a documented cross-unit dependency (`LayeredPipeline` unit → `ConnectorRouter` unit) that lets a shaped node's ports and endpoints reuse `ConnectorRouter`'s already-tested extent-restriction and surface-projection rules instead of -duplicating them. See _ConnectorRouter Unit Design_'s "Callers" section for the reverse-direction +duplicating them. See *ConnectorRouter Unit Design*'s "Callers" section for the reverse-direction documentation of this dependency. #### Layered Pipeline Callers The pipeline is assembled and run directly by `InterconnectionLayoutEngine`, and the public `LayeredLayoutAlgorithm` consumes it transitively through that engine when the layered algorithm is -selected. +selected. The boundary-port helpers (`HierarchyMergeRegionBuilder`, `BoundaryPortResolver`) are +consumed by `HierarchicalLayoutAlgorithm` to detect and reconcile a container's boundary ports. #### Layered Pipeline Interactions @@ -141,6 +166,9 @@ public layout result contract. | Rendering-Layout-LayeredPipeline-StagedPipeline | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-BehaviorPreserving | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-FlatHierarchyOnly | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-BoundaryPortDetection | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-BoundaryPortResolution | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-Directions | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-OrthogonalConnectors | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-CycleBreaking | Layered pipeline behavior described above | diff --git a/docs/design/rendering-layout/hierarchical-layout-algorithm.md b/docs/design/rendering-layout/hierarchical-layout-algorithm.md index 32f3343..bcb59c1 100644 --- a/docs/design/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/design/rendering-layout/hierarchical-layout-algorithm.md @@ -10,7 +10,10 @@ containment algorithms place a single flat scope, this engine lays out a *compou whose nodes may be containers of nested subgraphs — by recursively placing each container's children and composing the sub-layouts into one absolute `LayoutTree`. It does not place boxes itself; it selects a bundled *leaf* algorithm per scope and delegates the actual placement to it, then sizes each -container and composes the results. It is additive: it changes no existing output and is honored only +container and composes the results. It additionally resolves *boundary (delegation) ports* — a +container port that both receives an external approach edge and is referenced by an edge inside the +container's own child scope — reconciling each to one shared anchor on the container boundary carrying +both an external and an internal label. It is additive: it changes no existing output and is honored only when a caller selects it by name. ### HierarchicalLayoutAlgorithm Data Model @@ -74,14 +77,33 @@ overrides win over the supplied options, exactly like every nested scope), and c on the owning scope's graph is honored rather than falling back to the root options. Edges already routed by the leaf algorithm (both endpoints direct) or belonging to a lower scope (both endpoints under one container) are skipped. A genuine cross-container edge whose endpoint is a named - `LayoutGraphPort` — this scope's router is built on the box-only `ConnectorRouter`/`Connection` and - has no port concept — throws `NotSupportedException` instead of being routed or silently dropped; - full boundary-crossing port support (anchoring, routing) is a separate, not-yet-designed Phase 2 - effort (see ROADMAP.md). + `LayoutGraphPort` in a shape the boundary-port delegation mechanism does not cover — for example a + port owned by a plain (non-container) node with an edge straight into a different container — throws + `NotSupportedException` instead of being routed or silently dropped; that port's owner has no child + scope to delegate into, so this scope's box-only router (which has no port concept) cannot anchor it. + Broader boundary-crossing port support is a separate future effort (see ROADMAP.md). 7. **Assembly.** The engine returns a `LayoutTree` with the leaf algorithm's canvas size for this level and the composed boxes followed by the leaf-routed lines, any leaf-emitted port anchors, and the cross-container lines. +**Boundary (delegation) port reconciliation.** After the leaf pass places a scope, `LayoutScope` +collects that scope's boundary ports with `HierarchyMergeRegionBuilder` (part of the layered-pipeline +unit, in `Engine/Layered`): a container's port is a boundary port when an edge inside that container's +own `Children` references it — the inward delegation edge is the structural signal, detected +transitively and to unbounded depth by a recursive collect. *Only when at least one boundary port is +detected* does the engine reconcile the leaf pass output with `BoundaryPortResolver`; a scope with no +boundary port takes the identical existing code path, so flat and ordinary port-edge graphs are +byte-for-byte unchanged. The resolver reconciles each boundary port to one shared physical anchor on +the container boundary carrying both the external label (rendered outward) and the internal label +(rendered inward), consolidates external fan-out onto that anchor, and adds one internal delegation +connector per internal edge reaching into the container's placed interior. This is a *reconciliation* +of the anchor the per-scope leaf pass already produced — enriched with the internal label and internal +delegation connectors — rather than a single flattened cross-scope Sugiyama pass; the `AugNode` +hierarchy-crossing descriptor and the recursive pipeline path order same-face crossings. A fully +flattened joint cross-scope pass remains a documented future refinement (see ROADMAP.md); the current +reconciliation satisfies every boundary-port acceptance criterion (one shared anchor carrying both +labels, external and internal fan-out, and two independent boundary ports on one container). + Every `CoreOptions` property (`Algorithm`, `Direction`, `EdgeRouting`, `HierarchyHandling`, `NodeSpacing`, `LayerSpacing`) cascades through this same generalized mechanism, built once on `PropertyHolder.OverlayOnto` rather than threaded per property: a scope with no override of its own @@ -111,13 +133,15 @@ Null `graph`, `options`, or (injecting constructor) `registry` throw `ArgumentNu that selects an algorithm identifier absent from the registry surfaces the registry's `KeyNotFoundException`. An edge whose endpoint resolves to no node at all under the current scope (an out-of-scope reference) is skipped rather than treated as an error. A port edge that genuinely crosses -a container boundary — one endpoint's owning node nested inside a container relative to this scope -while the other is not, or an edge otherwise promoted into this scope's router because it shares a box -with such an edge — throws `NotSupportedException` with a message identifying named ports crossing a -container boundary as not yet supported; this scope's router has no port concept and full -boundary-crossing port support remains a separate Phase 2 effort. A same-scope port edge (neither -endpoint nested relative to this scope) is not an error case: it is routed locally by the leaf -algorithm exactly as it would be in a flat graph. +a container boundary in a shape the boundary-port delegation mechanism does not cover — for example a +port owned by a plain (non-container) node with an edge into a different container's nested child — +throws `NotSupportedException` with a message identifying named ports crossing a container boundary as +not supported; that port's owner has no child scope to delegate into, so this scope's router has no port +concept for it, and broader boundary-crossing port support remains a separate future effort. A +container's own boundary (delegation) port is not this error case: it is reconciled to one shared anchor +by `BoundaryPortResolver` as described above. A same-scope port edge (neither endpoint nested relative +to this scope) is likewise not an error case: it is routed locally by the leaf algorithm exactly as it +would be in a flat graph. ### HierarchicalLayoutAlgorithm Dependencies @@ -131,8 +155,10 @@ algorithm exactly as it would be in a flat graph. `CoreOptions.HierarchyHandling` for configuration, and `PropertyHolder.OverlayOnto` for building each scope's cascaded effective options snapshot. - **Layout units** — `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm` as bundled leaf - algorithms registered in the default registry, and `ConnectorRouter` for LCA cross-container edge - routing. See *ConnectorRouter Unit Design*. + algorithms registered in the default registry, `ConnectorRouter` for LCA cross-container edge + routing, and `HierarchyMergeRegionBuilder` / `BoundaryPortResolver` (layered-pipeline unit, + `Engine/Layered`) for boundary-port detection and reconciliation. See *ConnectorRouter Unit Design* + and *Layered Pipeline Unit Design*. No OTS runtime component or shared package is consumed. @@ -169,6 +195,8 @@ renderers and callers through the layout registry under the `"hierarchical"` ide | Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-HierarchyHandling | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-CrossContainerEdge | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation | HierarchicalLayoutAlgorithm behavior described above | +| Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-CascadesOptions | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-HonorsScopeEdgeRouting | HierarchicalLayoutAlgorithm behavior described above | | Rendering-Layout-HierarchicalLayout-ValidatesGraph | HierarchicalLayoutAlgorithm behavior described above | diff --git a/docs/design/rendering/layout-tree.md b/docs/design/rendering/layout-tree.md index b47f36d..cd77c90 100644 --- a/docs/design/rendering/layout-tree.md +++ b/docs/design/rendering/layout-tree.md @@ -33,12 +33,18 @@ the top-left, so a renderer can draw each element directly without resolving nes a renderer knows where title/compartment content may start and must stop without colliding with a port label. - `LayoutCompartment` (sealed record) — `Title` and text `Rows`. -- `LayoutPort` (node record) — `CentreX`, `CentreY`, attached `Side`, `Label`, and `MaxLabelWidth` - (defaulting to `double.PositiveInfinity`) — an optional upper bound, computed by a layout - algorithm from the owning box's placed width (a flat `LayoutPort` has no reference back to its - owning box, so this bound must be precomputed and carried on the port itself), that a renderer - uses to squeeze an excessively long port label rather than let it visually overlap the opposite - port's label region. +- `LayoutPort` (node record) — `CentreX`, `CentreY`, attached `Side`, `ExternalLabel`, + `InternalLabel` (defaulting to `null`), and `MaxLabelWidth` (defaulting to + `double.PositiveInfinity`). A plain port carries only an `ExternalLabel`, rendered inward beside the + glyph exactly as the single legacy label was (keeping every pre-existing call site byte-identical). A + boundary (delegation) port additionally carries an `InternalLabel`: its presence marks the port as a + boundary port and moves the `ExternalLabel` to the outward face while the `InternalLabel` reads + inward, so one physical anchor names both the connection reaching it from outside the container and + the delegation relaying it into the container's own child scope. `MaxLabelWidth` is an optional upper + bound, computed by a layout algorithm from the owning box's placed width (a `LayoutPort` has no + reference back to its owning box, so this bound must be precomputed and carried on the port itself), + that a renderer uses to squeeze an excessively long port label rather than let it visually overlap + the opposite port's label region. - `LayoutLine` (node record) — `Waypoints`, `SourceEnd`, `TargetEnd`, `LineStyle`, and `MidpointLabel`. - `Point2D` (sealed record) — `X` and `Y`. diff --git a/docs/reqstream/rendering-layout.yaml b/docs/reqstream/rendering-layout.yaml index dc5b17a..5d7b6e4 100644 --- a/docs/reqstream/rendering-layout.yaml +++ b/docs/reqstream/rendering-layout.yaml @@ -63,6 +63,9 @@ sections: - Rendering-Layout-LayeredPipeline-StagedPipeline - Rendering-Layout-LayeredPipeline-BehaviorPreserving - Rendering-Layout-LayeredPipeline-FlatHierarchyOnly + - Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor + - Rendering-Layout-LayeredPipeline-BoundaryPortDetection + - Rendering-Layout-LayeredPipeline-BoundaryPortResolution - Rendering-Layout-LayeredPipeline-Directions - Rendering-Layout-LayeredPipeline-OrthogonalConnectors - Rendering-Layout-LayeredPipeline-CycleBreaking @@ -169,6 +172,7 @@ sections: - Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm - Rendering-Layout-HierarchicalLayout-HierarchyHandling - Rendering-Layout-HierarchicalLayout-CrossContainerEdge + - Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation - Rendering-Layout-HierarchicalLayout-CascadesOptions - Rendering-Layout-HierarchicalLayout-HonorsScopeEdgeRouting - Rendering-Layout-HierarchicalLayout-ValidatesGraph diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 879cc84..045f327 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -35,13 +35,68 @@ sections: - id: Rendering-Layout-LayeredPipeline-FlatHierarchyOnly title: >- - The layered pipeline shall support flat (single-graph) hierarchy handling and shall reject - recursive hierarchy handling with a clear error until it is implemented. + The layered pipeline shall assemble the flat (single-graph) stage sequence by default, and + shall additionally assemble a runnable recursive-hierarchy stage sequence, via + AddRecursiveStages, when recursive hierarchy handling is requested, rather than rejecting it + with an error. justification: | - Flat handling reproduces the current behavior exactly; failing fast on the unimplemented - recursive mode prevents silently producing an unsupported layout. + Flat handling reproduces the current behavior exactly. Recursive handling — formerly a + fail-fast NotSupportedException placeholder — now assembles a genuinely runnable pipeline + path that the boundary-port resolver drives to order same-face hierarchy crossings, so the + pipeline no longer needs to reject the recursive mode. tests: - - LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException + - LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing + - LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline + + - id: Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor + title: >- + An augmented node shall be able to carry an optional hierarchy-crossing descriptor + identifying the container face a boundary-port crossing occupies, defaulting to none so a + node with no crossing is constructed byte-identically to before the descriptor existed. + justification: | + Ordering several boundary-port crossings that share one container face requires a per-node + record of which face each crossing occupies. Making the descriptor optional and defaulting + it to none keeps every existing flat-graph augmented node — and therefore the flat fast + path — byte-identical. + tests: + - LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline + + - id: Rendering-Layout-LayeredPipeline-BoundaryPortDetection + title: >- + The boundary-port merge-region builder shall structurally detect a container's boundary + ports — a port referenced by an edge inside that container's own child scope — across the + whole hierarchy, transitively and to unbounded depth, and shall not misclassify a + same-scope port or a leaf-node port as a boundary port. + justification: | + A boundary (delegation) port is recognized purely structurally: the inward delegation edge + referencing the port from within the container's children is the signal. Detecting this + across every level (via a recursive collect) makes boundary-port support general and + depth-unbounded, while excluding same-scope and leaf ports keeps flat and ordinary + port-edge graphs on their unchanged code path. + tests: + - Collect_NoPorts_ReturnsEmpty + - Collect_SameScopePort_NotDetectedAsBoundary + - Collect_DelegationPort_DetectedWithExternalAndInternalEdges + - Collect_TwoIndependentPorts_DetectsBoth + - CollectRecursive_ThreeLevelChain_ReportsEveryLevel + - Collect_PortOnLeafNode_NotDetected + + - id: Rendering-Layout-LayeredPipeline-BoundaryPortResolution + title: >- + The boundary-port resolver shall reconcile each detected boundary port to one shared anchor + on its container's boundary carrying both labels, add one internal delegation connector per + internal edge into the container's placed interior, and order same-face crossings + deterministically. + justification: | + Rather than a literal single flattened cross-scope pass, the resolver enriches the external + anchor already produced by the per-scope leaf pass with the internal label and internal + delegation connectors, consolidating external and internal fan-out onto one physical + anchor. Ordering same-face crossings keeps multiple crossings on one face deterministic. + tests: + - Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector + - OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices + - OrderCrossings_NoTargets_ReturnsInputOrder + - OrderCrossings_Empty_ReturnsEmpty - id: Rendering-Layout-LayeredPipeline-Directions title: >- diff --git a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml index 585f224..d7a44bf 100644 --- a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml @@ -121,19 +121,43 @@ sections: - Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePortEdge - Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates + - id: Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation + title: >- + HierarchicalLayoutAlgorithm shall resolve a container's boundary (delegation) port — a port + the container both exposes to an external approach edge and references from an edge inside + its own child scope — to exactly one shared LayoutPort anchor on the container boundary + carrying both the external and internal labels, with every external approach and every + internal delegation edge reaching that one anchor, including under external and internal + fan-out and for two independent boundary ports on one container. + justification: | + A boundary/delegation port is the bulkhead-connector pattern: one physical anchor names both + the connection reaching it from outside the container and the delegation relaying it into the + container's own children. Consolidating fan-out and reconciling both labels onto a single + anchor (rather than emitting a separate port per edge) keeps the rendered wiring unambiguous. + The resolution reconciles the anchor the per-scope leaf pass already produced with the + internal label and one delegation connector per internal edge, which satisfies the acceptance + criteria without a literal flattened cross-scope pass. + tests: + - Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels + - Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor + - Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors + - id: Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows title: >- HierarchicalLayoutAlgorithm shall throw NotSupportedException, rather than silently dropping - the edge, when a named-port edge genuinely crosses a container boundary. + the edge, when a named-port edge genuinely crosses a container boundary in a shape the + boundary-port delegation mechanism does not cover — for example a port owned by a plain + (non-container) node with an edge straight into a different container's nested child. justification: | - This scope's cross-container router is built on the box-only ConnectorRouter/Connection and - has no port concept, so it cannot anchor or route a port endpoint. Failing loudly with a clear, - actionable message is safer than silently dropping the edge (the previous, previously- - undocumented defect behavior) or anchoring it incorrectly as a plain box connector. Full - boundary-crossing port support (anchoring, routing) remains a separate, not-yet-designed Phase - 2 effort (see ROADMAP.md). + The boundary-port delegation mechanism covers a container's own boundary port delegating into + its children. A port whose owner has no child scope to delegate into is not a delegation port, + so it falls through to the box-only ConnectorRouter/Connection cross-container router, which + has no port concept and cannot anchor or route a port endpoint. Failing loudly with a clear, + actionable message is safer than silently dropping the edge or anchoring it incorrectly as a + plain box connector. Broader boundary-crossing port support remains a separate future effort + (see ROADMAP.md). tests: - - Apply_PortEdgeCrossingContainerBoundary_Throws + - Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws - id: Rendering-Layout-HierarchicalLayout-ValidatesGraph title: >- diff --git a/docs/reqstream/rendering/layout-tree.yaml b/docs/reqstream/rendering/layout-tree.yaml index 5ea3dd7..bf5a527 100644 --- a/docs/reqstream/rendering/layout-tree.yaml +++ b/docs/reqstream/rendering/layout-tree.yaml @@ -71,11 +71,17 @@ sections: - id: Rendering-Model-LayoutTree-Port title: >- - A port node shall carry its absolute centre, the box edge it is attached to, and an - optional label. + A port node shall carry its absolute centre, the box edge it is attached to, an optional + external label, and an optional internal label. justification: | Ports pin connection points to box edges; carrying the attached side lets a renderer - offset the port symbol and its label away from the box face correctly. + offset the port symbol and its labels away from the box face correctly. A plain port carries + only an external label (rendered inward beside the glyph, exactly as the single legacy label + was, keeping every pre-existing call site byte-identical). A boundary (delegation) port + additionally carries an internal label: its presence marks the port as a boundary port and + moves the external label to the outward face while the internal label reads inward, so one + physical anchor can name both the connection reaching it from outside the container and the + delegation relaying it into the container's own child scope. tests: - LayoutPort_Construction_StoresAllFields diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 4ec5eb3..1b51211 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -291,6 +291,50 @@ horizontally) the box's **width** grows to fit each label's actual rendered widt examples of preserved vs. merged parallel edges, horizontal vs. vertical port placement, and the horizontal- vs. vertical-flow parallel-edge auto-grow behavior. +## Boundary (delegation) ports + +A *boundary (delegation) port* is a named port on a **container** node that relays a connection from +outside the container into its nested children. It carries two independent, optional labels — an +`ExternalLabel` and an `InternalLabel` — and the hierarchical engine reconciles both onto one shared +physical anchor on the container boundary: the external label reads outward (away from the container) +and the internal label reads inward (into the container's interior). A port is recognized as a boundary +port purely structurally, when an edge *inside the container's own child scope* references it — the +inward delegation edge is the signal: + +```csharp +var graph = new LayoutGraph(); + +// A sibling node and a container node. +var sensor = graph.AddNode("sensor", width: 120, height: 50); +sensor.Label = "Sensor"; +var controller = graph.AddNode("controller", width: 10, height: 10); // grows to fit its children +controller.Label = "Controller"; + +// One boundary port carrying BOTH labels. +var command = controller.Ports.AddPort("command"); +command.ExternalLabel = "command"; // rendered outward +command.InternalLabel = "dispatch"; // rendered inward + +// Nested children the port delegates to. +var driver = controller.Children.AddNode("driver", width: 120, height: 50); +var logger = controller.Children.AddNode("logger", width: 120, height: 50); + +// The external approach edge lives in the root scope (sibling to the port). +graph.AddEdge("sensor-command", sensor, command); + +// The internal delegation edges live inside the container's own child scope; two of +// them exercise internal fan-out onto the one shared anchor. +controller.Children.AddEdge("command-driver", command, driver); +controller.Children.AddEdge("command-logger", command, logger); +``` + +The engine emits exactly one `LayoutPort` anchor for the boundary port, carrying both labels, with the +external approach edge and every internal delegation edge reaching that one anchor. External fan-out +(several sibling approach edges) and internal fan-out (several internal delegation edges) are both +consolidated onto the single shared anchor. The +[gallery's "Boundary and delegation ports" diagrams](../gallery/README.md) show complete, rendered +horizontal- and vertical-flow examples, including both fan-out directions. + ## Option cascading Every well-known option cascades: it can be set at the free-standing `LayoutOptions`, at a diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 4c8999a..c8fc41d 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -35,7 +35,8 @@ rejected. No stage is mocked — real `LayeredGraph` instances flow through ever A verification run passes when every named scenario below asserts without unexpected exception, and the referenced tests cover each `Rendering-Layout-LayeredPipeline-*` requirement. Any drift in per-stage geometry, in byte-identity with the legacy oracle on the random and named topologies, -in supported hierarchy handling (recursive input must throw), in flow directions, in orthogonal +in supported hierarchy handling (recursive input must assemble a runnable pipeline; boundary-port +detection and reconciliation must behave as specified), in flow directions, in orthogonal waypoint shape, in back-edge approach behavior, in component packing determinism, in shared `LayeredGraph` state validation, or in `LayeredLayoutPipeline` input validation constitutes a failure. @@ -51,9 +52,25 @@ failure. `Pipeline_MatchesLegacyOracle_OnEmptyGraph`, `Pipeline_MatchesLegacyOracle_OnDroneLikeGraph`, `Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph`, and `Pipeline_MatchesLegacyOracle_OnNamedTopologies`. -- **Flat hierarchy only** (`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`): - `LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException` confirms recursive - handling fails fast. +- **Flat and recursive hierarchy handling** (`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`): + `LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline` confirms recursive + hierarchy handling now assembles a runnable pipeline rather than throwing, alongside the flat default + sequence exercised by + `LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing`. +- **Hierarchy-crossing descriptor** (`Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor`): + `LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline` exercises the recursive + path that consumes the optional `AugNode` hierarchy-crossing descriptor. +- **Boundary-port detection** (`Rendering-Layout-LayeredPipeline-BoundaryPortDetection`): + `Collect_NoPorts_ReturnsEmpty`, `Collect_SameScopePort_NotDetectedAsBoundary`, + `Collect_DelegationPort_DetectedWithExternalAndInternalEdges`, `Collect_TwoIndependentPorts_DetectsBoth`, + `CollectRecursive_ThreeLevelChain_ReportsEveryLevel`, and `Collect_PortOnLeafNode_NotDetected` confirm + `HierarchyMergeRegionBuilder` detects a container's boundary ports transitively and to unbounded depth + while excluding same-scope and leaf-node ports. +- **Boundary-port resolution** (`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`): + `Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector` confirms `BoundaryPortResolver` + enriches the leaf-pass anchor with the internal label and adds the internal delegation connector, and + `OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices`, `OrderCrossings_NoTargets_ReturnsInputOrder`, + and `OrderCrossings_Empty_ReturnsEmpty` confirm deterministic same-face crossing ordering. - **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, @@ -152,7 +169,18 @@ failure. Pipeline_MatchesLegacyOracle_OnDroneLikeGraph, Pipeline_MatchesLegacyOracle_OnWorkstationLikeGraph, Pipeline_MatchesLegacyOracle_OnNamedTopologies - **`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`**: - LayeredLayoutPipeline_Build_RecursiveHierarchy_ThrowsNotSupportedException + LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing, + LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline +- **`Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor`**: + LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline +- **`Rendering-Layout-LayeredPipeline-BoundaryPortDetection`**: + Collect_NoPorts_ReturnsEmpty, Collect_SameScopePort_NotDetectedAsBoundary, + Collect_DelegationPort_DetectedWithExternalAndInternalEdges, Collect_TwoIndependentPorts_DetectsBoth, + CollectRecursive_ThreeLevelChain_ReportsEveryLevel, Collect_PortOnLeafNode_NotDetected +- **`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`**: + Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector, + OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices, OrderCrossings_NoTargets_ReturnsInputOrder, + OrderCrossings_Empty_ReturnsEmpty - **`Rendering-Layout-LayeredPipeline-Directions`**: AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged, AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces, diff --git a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md index 1c74fa8..7589f36 100644 --- a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md @@ -32,7 +32,8 @@ the referenced tests cover each `Rendering-Layout-HierarchicalLayout-*` requirem the stable identifier (`"hierarchical"`), in the flat-graph byte-equivalence guarantee, in container sizing (padding, title band), in per-scope algorithm resolution, in cross-container LCA edge routing, in same-scope named-port edge routing (or its ancestor-coordinate translation), in -the boundary-crossing port `NotSupportedException` behavior, or in the argument-null validation +the boundary (delegation) port reconciliation behavior, in the narrowed boundary-crossing port +`NotSupportedException` behavior, or in the argument-null validation behavior constitutes a failure. Mutation of input node sizes also constitutes a failure. ### Test Scenarios @@ -90,11 +91,21 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a `Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates` confirms a `LayoutPort` emitted by a nested container's own leaf pass is correctly translated into the ancestor's absolute coordinates when composed, landing within the composed container box's bounds. +- **Boundary port delegation** (`Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation`): + `Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels` confirms a + container's boundary port resolves to exactly one `LayoutPort` anchor carrying both the external and + internal labels on the container boundary, with both the external approach edge and the internal + delegation connector reaching that one anchor. + `Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor` confirms external and internal fan-out (two + approach edges and two delegation edges) still consolidate onto one shared anchor, and + `Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors` confirms two independent boundary + ports on one container each resolve to their own shared anchor. - **Boundary port edge throws** (`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`): - `Apply_PortEdgeCrossingContainerBoundary_Throws` confirms an edge from a root-level named port - directly to a node nested inside a separate container — a genuine boundary-crossing port edge — - throws `NotSupportedException` with a message identifying named ports crossing a container boundary - as not yet supported, rather than the edge being silently dropped. + `Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws` confirms a port owned by a plain + (non-container) node with an edge straight into a different container's nested child — a + boundary-crossing port edge the delegation mechanism does not cover — throws `NotSupportedException` + with a message identifying named ports crossing a container boundary as not supported, rather than the + edge being silently dropped. - **Validation** (`Rendering-Layout-HierarchicalLayout-ValidatesGraph`, `Rendering-Layout-HierarchicalLayout-ValidatesOptions`, `Rendering-Layout-HierarchicalLayout-ValidatesRegistry`): `Apply_NullGraph_Throws`, @@ -125,8 +136,12 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a - **`Rendering-Layout-HierarchicalLayout-SameScopePortEdges`**: Apply_SameScopePortEdge_WithUnrelatedContainerElsewhere_RoutesLikePortEdge, Apply_NestedContainerPortEdge_TranslatesPortIntoAncestorCoordinates +- **`Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation`**: + Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels, + Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor, + Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors - **`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`**: - Apply_PortEdgeCrossingContainerBoundary_Throws + Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws - **`Rendering-Layout-HierarchicalLayout-ValidatesGraph`**: Apply_NullGraph_Throws - **`Rendering-Layout-HierarchicalLayout-ValidatesOptions`**: diff --git a/docs/verification/rendering/layout-tree.md b/docs/verification/rendering/layout-tree.md index 9bdabd4..b6e8e09 100644 --- a/docs/verification/rendering/layout-tree.md +++ b/docs/verification/rendering/layout-tree.md @@ -80,8 +80,11 @@ back its own value independently of the others. #### Port carries all fields -Test `LayoutPort_Construction_StoresAllFields` constructs a port with centre, side, and label set and -asserts that `CentreX`, `CentreY`, `Side`, and `Label` equal the supplied values. +Test `LayoutPort_Construction_StoresAllFields` constructs a port with centre, side, and external label +set and asserts that `CentreX`, `CentreY`, `Side`, and `ExternalLabel` equal the supplied values and +that `InternalLabel` defaults to `null` (a plain, non-boundary port). The optional `InternalLabel`, +when set, marks a boundary (delegation) port that carries both an outward external and an inward +internal label at one anchor. **Covers**: `Rendering-Model-LayoutTree-Port`. From 1ff5dda43018be4d0de7b65411ed8adebdf30ca9 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 21:43:46 -0400 Subject: [PATCH 09/23] test+docs: add both-labels renderer tests; make crossing-dummy scaffolding docs honest Address quality-review MEDIUM concerns: - Add SVG and Skia renderer tests exercising a boundary port with both ExternalLabel and InternalLabel (internal inward, external outward about the shared anchor) plus external-label-only legacy-parity assertions. - Correct XML docs that overstated the hierarchy-crossing dummy: the descriptor/OrderCrossings primitive are unit-tested and reserved for the future fully-joint pass; production placement is reconciliation-based and does not populate AugNode.Crossing. Fix the misleading discarded-result comment in SynthesizeAnchor accordingly. --- .../Engine/Layered/BoundaryPortResolver.cs | 20 +++-- .../Engine/Layered/HierarchyHandling.cs | 33 +++++--- .../Engine/Layered/LayeredGraph.cs | 22 +++-- .../Engine/Layered/LayeredLayoutPipeline.cs | 11 ++- .../SkiaPortAndContentInsetTests.cs | 83 +++++++++++++++++++ .../SvgRendererPortedTests.cs | 73 ++++++++++++++++ 6 files changed, 210 insertions(+), 32 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs index 97d46f6..5f2a421 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs @@ -109,11 +109,14 @@ public static BoundaryResolution Resolve( /// sorted by their placed cross-axis position. /// /// - /// This is the combined-pass core of the recursive hierarchy handling exposed for direct unit - /// testing: each crossing is a zero-size hierarchy-crossing dummy that - /// participates in the same layer-assignment, crossing-minimization, and Brandes-Köpf placement - /// stages as an ordinary node, so the relative order the pipeline assigns the crossings is the - /// order they should occupy along the shared boundary face. + /// This is the combined-pass ordering primitive for the recursive hierarchy handling, exposed for + /// direct unit testing: it builds a small flattened graph — one zero-size crossing dummy per + /// crossing feeding the interior targets — and runs the recursive-mode pipeline so the relative + /// order the pipeline assigns the crossings is the order they should occupy along the shared + /// boundary face. It is reserved for the fully-joint pass and for ordering several crossings that + /// share one face; the current production reconciliation aligns each boundary anchor by + /// mean-of-targets (see ) rather than consuming this + /// ordering, so today this method is covered by unit tests but does not yet govern placement. /// /// The hierarchy crossings to order, one zero-size dummy each. /// The interior target node sizes each crossing delegates to, in order. @@ -375,8 +378,11 @@ private static Point2D SynthesizeAnchor( IReadOnlyList targets, LayoutDirection direction) { - // Exercise the combined layered ordering so a synthesized anchor's along-face position is - // governed by the same pass that would order several crossings sharing this face. + // Exercise the combined layered ordering primitive over this face's single crossing so the + // reserved joint-pass path stays live and covered. Its result does not govern placement today: + // with one crossing there is nothing to reorder, and the anchor's along-face position is set by + // the mean-of-targets alignment below. Feeding OrderCrossings back for multi-crossing faces is + // the reserved refinement (see HierarchyHandling.Recursive). var crossings = new[] { new HierarchyCrossing(new LayoutGraphPort("crossing"), HierarchyCrossingFace.Internal) }; var targetSizes = targets.Select(t => (t.Width, t.Height)).ToList(); _ = OrderCrossings(crossings, targetSizes, direction); diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs index e79e7d3..2f8693b 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs @@ -14,16 +14,24 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// /// /// selects the ELK-style compound-graph handling used when an edge with a -/// named boundary port crosses a container boundary: the portion of the node hierarchy the -/// crossing actually touches is flattened into one graph, each boundary crossing is represented as -/// a hierarchy-crossing dummy node that participates in the same single layer-assignment and -/// crossing-minimization pass as ordinary long-edge dummies, and each container's own size is still -/// reconciled bottom-up (children sized first) before the joint pass positions the crossing point -/// consistently with both the container's placement among its siblings and its children's placement -/// inside it. The merge region is general and transitive: it is the minimal footprint -/// touched by boundary-crossing edges — not fixed at two levels and not one boundary port at a -/// time — so it follows a chain of delegation ports to arbitrary depth and merges any number of -/// independent boundary ports on one container together in a single combined pass. +/// named boundary port crosses a container boundary. Each container is still sized bottom-up +/// (children first) and the existing per-scope leaf pass anchors the external side of every +/// boundary crossing exactly as it always has; a reconciliation step then merges each boundary +/// port's external anchor and its interior delegation targets into a single shared boundary +/// anchor carrying both labels, and routes the internal delegation connectors from that anchor to +/// the children the port feeds. The merge region the reconciliation covers is general and +/// transitive: it is the minimal footprint touched by boundary-crossing edges — not fixed at +/// two levels and not one boundary port at a time — so it follows a chain of delegation ports to +/// arbitrary depth and merges any number of independent boundary ports on one container together. +/// +/// +/// The end state this mode is designed toward is a single fully-joint pass that flattens the +/// touched hierarchy and lays every crossing out as a hierarchy-crossing dummy alongside ordinary +/// long-edge dummies (see ). The current implementation reaches the +/// same general/transitive external result by reconciliation rather than that literal +/// flattened pass; the hierarchy-crossing dummy descriptor and its combined ordering primitive +/// (BoundaryPortResolver.OrderCrossings) are exercised by unit tests and reserved for that +/// joint pass, and do not yet govern production anchor placement. /// /// internal enum HierarchyHandling @@ -32,8 +40,9 @@ internal enum HierarchyHandling Flat, /// - /// Flatten the portion of the hierarchy touched by boundary-crossing port edges into one graph and - /// resolve each crossing as a hierarchy-crossing dummy in a single combined layered pass. + /// Handle boundary-crossing port edges over the general/transitive merge region touched by the + /// crossings, reconciling each into a single shared dual-label boundary anchor with internal + /// delegation connectors (the reserved fully-joint flattened pass is approximated by reconciliation). /// Recursive, } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index 4b4abe2..5c02911 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -30,11 +30,14 @@ internal enum HierarchyCrossingFace /// /// /// Generalizes LongEdgeSplitter's zero-size intermediate-layer dummy from "spans layers -/// within one scope" to "spans layers across nested scopes": a hierarchy-crossing dummy participates -/// in the same layer-assignment, crossing-minimization, and placement pass as ordinary dummies, but -/// additionally remembers the originating and which of the -/// container boundary it represents, so the placed dummy coordinate can be reconciled with the -/// owning container's own box placement into a single shared boundary anchor. +/// within one scope" to "spans layers across nested scopes". In the fully-joint pass this mode is +/// designed toward, a hierarchy-crossing dummy would participate in the same layer-assignment, +/// crossing-minimization, and placement pass as ordinary dummies while additionally remembering the +/// originating and which of the container boundary it stands +/// in for. That descriptor is reserved scaffolding: the current recursive handling reaches the same +/// general/transitive external result by reconciliation (see ) +/// and does not yet populate hierarchy-crossing dummies into the augmented graph, so this type is +/// currently exercised only by the ordering primitive's unit tests. /// /// The originating input-graph boundary port this dummy stands in for. /// Which logical face (external/internal) of the boundary crossing this dummy is. @@ -46,10 +49,11 @@ internal enum HierarchyCrossingFace /// Assigned Sugiyama layer index. /// Whether this node is a zero-size long-edge dummy. /// -/// When non-, marks this node as a hierarchy-crossing dummy standing in for a -/// boundary port (rather than an ordinary long-edge dummy), carrying the originating port and its face. -/// for every real node and every ordinary long-edge dummy, so the default -/// construction and every existing caller stay byte-identical. +/// Reserved scaffolding for the fully-joint flattened pass: when non- it would +/// mark this node as a hierarchy-crossing dummy standing in for a boundary port. The current +/// reconciliation-based recursive handling never assigns it, so it is for every +/// real node and every long-edge dummy, keeping default construction and every existing caller +/// byte-identical. /// internal sealed record AugNode( double Width, diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs index b88d662..6136a10 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs @@ -117,10 +117,13 @@ public PipelineBuilder AddDefaultStages() /// hierarchy-crossing dummy participates in exactly the same layer-assignment, /// crossing-minimization, and placement stages as an ordinary node or long-edge dummy — ELK's /// own compound-graph design. The Recursive-specific behavior is not a distinct stage but the - /// way the caller seeds the graph with zero-size hierarchy-crossing dummies (carrying a - /// descriptor) and reads their placed positions back out; this - /// method exists so a Recursive pipeline is assembled through its own explicit entry point, - /// keeping the Flat path untouched. + /// way the caller seeds the graph with zero-size crossing dummies and reads their placed + /// positions back out (see BoundaryPortResolver.OrderCrossings, the ordering primitive + /// that drives this pipeline). This method exists so a Recursive pipeline is assembled through + /// its own explicit entry point, keeping the Flat path untouched. + /// Today the primitive is unit-tested and reserved for the fully-joint pass; production placement + /// is reconciliation-based (see ), so the seeded dummies + /// do not yet carry a descriptor into the augmented graph. /// /// This builder, for chaining. public PipelineBuilder AddRecursiveStages() diff --git a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs index 2e183a0..c2cdadf 100644 --- a/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs +++ b/test/DemaConsulting.Rendering.Skia.Tests/SkiaPortAndContentInsetTests.cs @@ -60,6 +60,89 @@ public void PngRenderer_RenderPort_AnySide_ProducesNonBackgroundPixels(PortSide Assert.True(hasForeground); } + /// + /// Proves the boundary-port dual-label rule in the raster renderer: a left-side port carrying + /// both an external and an internal label draws foreground pixels on BOTH sides of the port + /// glyph (the external label outward/left, the internal label inward/right), whereas an + /// external-label-only port draws its single label inward only, leaving the outward side blank. + /// This guards the both-labels geometry the SVG unit test asserts, mirrored in the raster path. + /// + [Fact] + public void PngRenderer_RenderPort_BothLabels_DrawsLabelsOnBothSidesOfPort() + { + // Arrange: identical left-side ports, one with both labels, one external-only. + var renderer = new PngRenderer(); + var options = new RenderOptions(Themes.Light); + const int portCentreX = 100; + + var bothLabels = RenderToBitmap( + renderer, options, new LayoutPort(portCentreX, 50, PortSide.Left, ExternalLabel: "ext", InternalLabel: "int")); + var externalOnly = RenderToBitmap( + renderer, options, new LayoutPort(portCentreX, 50, PortSide.Left, ExternalLabel: "solo")); + + try + { + var background = SKColor.Parse(Themes.Light.BackgroundColor); + + // Regions strictly outside the port glyph: outward (left of the port) and inward (right). + // The glyph itself spans a few pixels around the centre, so leave a margin either side. + const int outwardMaxX = 88; // columns 0..88 lie left of the glyph (outward face) + const int inwardMinX = 112; // columns 112.. lie right of the glyph (inward face) + + // Both-labels port: foreground appears on both the outward and inward sides. + Assert.True( + HasForegroundInColumnRange(bothLabels, background, 0, outwardMaxX), + "both-labels port should draw its external label on the outward (left) side"); + Assert.True( + HasForegroundInColumnRange(bothLabels, background, inwardMinX, bothLabels.Width - 1), + "both-labels port should draw its internal label on the inward (right) side"); + + // External-only port: nothing outward; the single label reads inward only. + Assert.False( + HasForegroundInColumnRange(externalOnly, background, 0, outwardMaxX), + "external-only port must not draw anything on the outward (left) side"); + Assert.True( + HasForegroundInColumnRange(externalOnly, background, inwardMinX, externalOnly.Width - 1), + "external-only port should draw its label inward (right) exactly like a legacy port"); + } + finally + { + bothLabels.Dispose(); + externalOnly.Dispose(); + } + } + + /// Renders a single port on a 200x100 layout and decodes the PNG into a bitmap. + private static SKBitmap RenderToBitmap(PngRenderer renderer, RenderOptions options, LayoutPort port) + { + using var stream = new MemoryStream(); + renderer.Render(new LayoutTree(200, 100, [port]), options, stream); + stream.Position = 0; + using var data = SKData.Create(stream); + var bitmap = SKBitmap.Decode(data); + Assert.NotNull(bitmap); + return bitmap!; + } + + /// Returns whether any non-background pixel exists in the inclusive column range [minX, maxX]. + private static bool HasForegroundInColumnRange(SKBitmap bitmap, SKColor background, int minX, int maxX) + { + var lo = Math.Max(0, minX); + var hi = Math.Min(bitmap.Width - 1, maxX); + for (var x = lo; x <= hi; x++) + { + for (var y = 0; y < bitmap.Height; y++) + { + if (bitmap.GetPixel(x, y) != background) + { + return true; + } + } + } + + return false; + } + /// /// Proves that a box's non-zero shifts the leftmost /// drawn compartment-row pixel further right than an otherwise-identical box with no content diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs index 1cf8dfc..5a98b62 100644 --- a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs @@ -376,6 +376,79 @@ public void SvgRenderer_RenderPort_TopBottomLabel_ReadsInward(PortSide side, boo } } + /// + /// Proves the boundary-port dual-label rule: when a port carries both an + /// and an , the + /// internal label renders inward (toward the box interior) and the external label renders + /// outward (away from the interior) on the opposite face, and both sit symmetrically about the + /// single shared port anchor. Uses a left-side port so inward means larger x and outward means + /// smaller x. + /// + [Fact] + public void SvgRenderer_RenderPort_BothLabels_InternalInwardExternalOutwardAboutSharedAnchor() + { + // Arrange: a left-side boundary port carrying both an external and an internal label. + var renderer = new SvgRenderer(); + var port = new LayoutPort(100, 50, PortSide.Left, ExternalLabel: "ext", InternalLabel: "int"); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: two labels, one per side of the port centre, symmetric about it. + output.Position = 0; + var svgText = ReadAllText(output); + var internalX = ExtractLabelX(svgText, "int"); + var externalX = ExtractLabelX(svgText, "ext"); + + // Internal reads inward (toward the interior / larger x for a left-side port); external reads + // outward (away from the interior / smaller x). + Assert.True(internalX > port.CentreX, $"internal label x={internalX} should be inward of port x={port.CentreX}"); + Assert.True(externalX < port.CentreX, $"external label x={externalX} should be outward of port x={port.CentreX}"); + + // Both labels are offset by the same magnitude about the shared anchor (mirror-formula). + var inwardOffset = internalX - port.CentreX; + var outwardOffset = port.CentreX - externalX; + Assert.Equal(inwardOffset, outwardOffset, precision: 6); + } + + /// + /// Proves that an alone (no internal label) still reads + /// inward exactly as a legacy single-label port does, guaranteeing the dual-label machinery does + /// not disturb the byte-identical behavior every existing plain-port call site relies on. + /// + [Fact] + public void SvgRenderer_RenderPort_ExternalLabelOnly_ReadsInwardLikeLegacy() + { + // Arrange: a left-side port carrying only an external label (a plain, non-boundary port). + var renderer = new SvgRenderer(); + var port = new LayoutPort(100, 50, PortSide.Left, ExternalLabel: "solo"); + var layout = new LayoutTree(200, 100, [port]); + var options = new RenderOptions(Themes.Light); + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: exactly one label, reading inward (larger x for a left-side port). + output.Position = 0; + var svgText = ReadAllText(output); + var labelX = ExtractLabelX(svgText, "solo"); + Assert.True(labelX > port.CentreX, $"external-only label x={labelX} should read inward of port x={port.CentreX}"); + } + + /// Extracts the x attribute of the <text> element whose content equals . + private static double ExtractLabelX(string svg, string text) + { + var match = System.Text.RegularExpressions.Regex.Match( + svg, + $"""]*>{System.Text.RegularExpressions.Regex.Escape(text)}"""); + Assert.True(match.Success, $"expected a label with content '{text}'"); + return double.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + } + /// /// Proves that a box's non-zero pushes its /// compartment row text further right than an otherwise-identical box with no content From c82f819e4e24f4da5c8c8f70cbbf4780b2345611 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 8 Jul 2026 22:26:44 -0400 Subject: [PATCH 10/23] fix(layout): resolve boundary ports by LayoutGraphPort reference identity, not ExternalLabel BoundaryPortResolver previously matched a boundary port to its leaf-placed anchor (FindLeafAnchors) and matched nested delegation-chain targets (ResolveInteriorTargets) by string.Equals on the optional, frequently-null LayoutGraphPort.ExternalLabel. Two independent boundary ports on the same container that shared (or both left null) their ExternalLabel were silently mis-reconciled: one anchor absorbed both ports' external connectors while the other anchor was left with none. Fix: thread a stable identity channel end-to-end. - LayoutPort gains an optional trailing SourcePort (LayoutGraphPort?) field: engine-only plumbing identity, ignored by renderers. - LayeredLayoutAlgorithm tags each emitted LayoutPort with its originating emission.SourcePort/TargetPort. - HierarchicalLayoutAlgorithm.BuildSizedView now also returns the view-clone port map, and the hierarchical path translates each placed port's SourcePort from the sized-view clone back to the original scope's LayoutGraphPort before boundary-port resolution runs (the leaf pass runs over a cloned view graph whose ports are distinct objects from the original scope's ports). - BoundaryPortResolver.FindLeafAnchors and ResolveInteriorTargets's nested-port branch now match by ReferenceEquals(..., SourcePort) instead of ExternalLabel string equality; ResolveOne tags its emitted anchor with SourcePort so delegation chains resolve by identity too. Adds regression tests asserting connector *provenance* (not just anchor count/ label pairing) for shared-null and shared-identical ExternalLabel cases, both at the BoundaryPortResolver unit level and the full HierarchicalLayoutAlgorithm pipeline level. Updates reqstream/verification companion docs accordingly. Fixes the BLOCKING finding in .agent-logs/quality-stage0-1-hierarchical-ports-4e2a91.md, per the retry plan in .agent-logs/planning-retry1-boundary-port-identity-fix-9b4f2d.md. --- .../engine/layered-pipeline.yaml | 1 + .../hierarchical-layout-algorithm.yaml | 2 + .../engine/layered-pipeline.md | 12 +- .../hierarchical-layout-algorithm.md | 10 +- .../Engine/Layered/BoundaryPortResolver.cs | 21 ++- .../HierarchicalLayoutAlgorithm.cs | 31 +++- .../LayeredLayoutAlgorithm.cs | 6 +- .../LayoutTree/LayoutPort.cs | 14 +- .../Layered/BoundaryPortResolverTests.cs | 117 ++++++++++++- .../HierarchicalLayoutAlgorithmTests.cs | 159 ++++++++++++++++++ 10 files changed, 355 insertions(+), 18 deletions(-) diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 045f327..599fd59 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -94,6 +94,7 @@ sections: anchor. Ordering same-face crossings keeps multiple crossings on one face deterministic. tests: - Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector + - Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity - OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices - OrderCrossings_NoTargets_ReturnsInputOrder - OrderCrossings_Empty_ReturnsEmpty diff --git a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml index d7a44bf..4e5b6b4 100644 --- a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml @@ -141,6 +141,8 @@ sections: - Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels - Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor - Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors + - Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance + - Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance - id: Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows title: >- diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index c8fc41d..4268912 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -68,9 +68,14 @@ failure. while excluding same-scope and leaf-node ports. - **Boundary-port resolution** (`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`): `Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector` confirms `BoundaryPortResolver` - enriches the leaf-pass anchor with the internal label and adds the internal delegation connector, and - `OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices`, `OrderCrossings_NoTargets_ReturnsInputOrder`, - and `OrderCrossings_Empty_ReturnsEmpty` confirm deterministic same-face crossing ordering. + enriches the leaf-pass anchor with the internal label and adds the internal delegation connector, + `Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity` confirms + the leaf-anchor and nested-target matching is keyed on `LayoutPort.SourcePort` reference identity — + not on the optional, frequently-null `ExternalLabel` string — so two independent boundary ports on one + container that both leave `ExternalLabel` null resolve to their own true external connector, never + each other's, and `OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices`, + `OrderCrossings_NoTargets_ReturnsInputOrder`, and `OrderCrossings_Empty_ReturnsEmpty` confirm + deterministic same-face crossing ordering. - **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, @@ -179,6 +184,7 @@ failure. CollectRecursive_ThreeLevelChain_ReportsEveryLevel, Collect_PortOnLeafNode_NotDetected - **`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`**: Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector, + Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity, OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices, OrderCrossings_NoTargets_ReturnsInputOrder, OrderCrossings_Empty_ReturnsEmpty - **`Rendering-Layout-LayeredPipeline-Directions`**: diff --git a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md index 7589f36..60e874a 100644 --- a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md @@ -100,6 +100,12 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a approach edges and two delegation edges) still consolidate onto one shared anchor, and `Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors` confirms two independent boundary ports on one container each resolve to their own shared anchor. + `Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance` and + `Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance` go beyond + anchor count and label pairing to assert connector *provenance*: each anchor's external connector is + traced back to its own true external sibling and its internal connector to its own true child, with no + cross-wiring, in the case (a shared or both-null `ExternalLabel`) that previously caused the resolver's + label-string matching to silently mis-reconcile the two ports' anchors. - **Boundary port edge throws** (`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`): `Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws` confirms a port owned by a plain (non-container) node with an edge straight into a different container's nested child — a @@ -139,7 +145,9 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a - **`Rendering-Layout-HierarchicalLayout-BoundaryPortDelegation`**: Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels, Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor, - Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors + Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors, + Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance, + Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance - **`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`**: Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws - **`Rendering-Layout-HierarchicalLayout-ValidatesGraph`**: diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs index 5f2a421..5dde722 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs @@ -242,14 +242,17 @@ private static void ResolveOne( maxLabelWidth = Math.Max(0.0, (containerBox.Width / 2.0) - PortLabelClearance); } - // Emit the single enriched anchor carrying both labels. + // Emit the single enriched anchor carrying both labels. SourcePort tags this anchor with its + // originating graph port so a parent scope's own ResolveInteriorTargets nested-port branch can + // later find it by reference identity, not by label text (see FindLeafAnchors). ports.Add(new LayoutPort( anchorPoint.X, anchorPoint.Y, side, boundary.Port.ExternalLabel, boundary.Port.InternalLabel, - maxLabelWidth)); + maxLabelWidth, + SourcePort: boundary.Port)); // Wire each internal delegation edge to the shared anchor with an orthogonal connector. foreach (var target in targets) @@ -295,8 +298,10 @@ private static List ResolveInteriorTargets(BoundaryPort boundary, LayoutBo case LayoutGraphPort nestedPort: // A delegation chain link: the interior target is another container's boundary port, // already anchored inside this container by that container's own resolution pass. - var anchor = childPorts.FirstOrDefault( - cp => string.Equals(cp.ExternalLabel, nestedPort.ExternalLabel, StringComparison.Ordinal)); + // Matched by reference identity (SourcePort), not by ExternalLabel, since that field + // is optional and frequently null — two distinct nested ports sharing (or both + // omitting) a label must never be conflated. + var anchor = childPorts.FirstOrDefault(cp => ReferenceEquals(cp.SourcePort, nestedPort)); if (anchor != null) { targets.Add(new Rect(anchor.CentreX, anchor.CentreY, 0.0, 0.0)); @@ -314,7 +319,11 @@ private static List ResolveInteriorTargets(BoundaryPort boundary, LayoutBo /// /// Finds the leaf pass's emitted anchors for a boundary port: the ports lying on the container box's - /// boundary whose external label matches the boundary port's, in the order the leaf emitted them. + /// boundary whose is reference-identical to the boundary port, in + /// the order the leaf emitted them. Matching is by reference identity, not by + /// , since that field is optional and frequently null — two + /// independent boundary ports on the same container that share (or both omit) an external label must + /// still resolve to their own true leaf anchor, never each other's. /// /// The boundary port whose leaf anchors are sought. /// The container box the anchors must lie on. @@ -327,7 +336,7 @@ private static List FindLeafAnchors( { return ports .Where(candidate => - string.Equals(candidate.ExternalLabel, port.ExternalLabel, StringComparison.Ordinal) && + ReferenceEquals(candidate.SourcePort, port) && candidate.InternalLabel is null && OnBoxBoundary(candidate.CentreX, candidate.CentreY, containerBox)) .ToList(); diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index c3717e1..d8552ff 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -259,7 +259,7 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var (leafEdges, routedEdges) = ClassifyEdges(graph, descendantToDirect, portOwner); - var (view, _) = BuildSizedView(graph, effectiveSize, leafEdges); + var (view, _, viewPortOf) = BuildSizedView(graph, effectiveSize, leafEdges); // Place this level with the resolved leaf algorithm over the sized view. The leaf algorithm // emits one box per node in Nodes order, so placed boxes align with graph.Nodes by index. @@ -268,6 +268,21 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var placedLines = placed.Nodes.OfType().ToList(); var placedPorts = placed.Nodes.OfType().ToList(); + // The leaf pass ran over the sized view graph, whose ports are distinct clone objects from this + // scope's original graph ports (see BuildSizedView). Translate each placed port's SourcePort + // back to the original scope's LayoutGraphPort so downstream boundary-port resolution reasons + // about the same port identity as HierarchyMergeRegionBuilder/BoundaryPortResolver, never the + // disposable view clone. + if (viewPortOf.Count > 0) + { + var originalPortOf = viewPortOf.ToDictionary(kv => kv.Value, kv => kv.Key); + placedPorts = placedPorts + .Select(p => p.SourcePort != null && originalPortOf.TryGetValue(p.SourcePort, out var orig) + ? p with { SourcePort = orig } + : p) + .ToList(); + } + var (composed, indexOf) = ComposeBoxes(graph, placedBoxes, subLayouts); var crossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); @@ -366,8 +381,16 @@ private void LayoutContainerChildren( /// The caller's scope, which is never mutated. /// Effective sizes for the container nodes of this scope. /// The edges this scope's leaf algorithm should route locally. - /// The sized view graph and a map from each original node to its view counterpart. - private static (LayoutGraph View, Dictionary ViewOf) BuildSizedView( + /// + /// The sized view graph, a map from each original node to its view counterpart, and a map from each + /// original port to its view counterpart (surfaced so callers can translate a placed + /// 's back to the original scope's port + /// identity once the leaf pass has run over the view). + /// + private static ( + LayoutGraph View, + Dictionary ViewOf, + Dictionary ViewPortOf) BuildSizedView( LayoutGraph graph, Dictionary effectiveSize, HashSet leafEdges) @@ -429,7 +452,7 @@ private static (LayoutGraph View, Dictionary V } } - return (view, viewOf); + return (view, viewOf, viewPortOf); } /// diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index 8ab2f8c..bd0c45d 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -412,7 +412,8 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) anchor.Y, ResolveSide(anchor, result.Rects[emission.Source]), emission.SourcePort.ExternalLabel, - MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Source]))); + MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Source]), + SourcePort: emission.SourcePort)); } if (emission.TargetPort != null) @@ -423,7 +424,8 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) anchor.Y, ResolveSide(anchor, result.Rects[emission.Target]), emission.TargetPort.ExternalLabel, - MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Target]))); + MaxLabelWidth: ResolveMaxLabelWidth(result.Rects[emission.Target]), + SourcePort: emission.TargetPort)); } } diff --git a/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs index bef6105..c59531d 100644 --- a/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs +++ b/src/DemaConsulting.Rendering/LayoutTree/LayoutPort.cs @@ -48,10 +48,22 @@ public enum PortSide /// Defaults to (no constraint), which preserves the behavior of /// every pre-existing 4-argument new LayoutPort(x, y, side, label) call site. /// +/// +/// Engine-only plumbing identity: the that produced this placed anchor +/// during the leaf layout pass, if any. This exists solely so a later reconciliation stage (such as the +/// hierarchical layout algorithm's boundary-port resolver) can recover, by reference identity, which +/// graph port originated a given placed anchor — this is required because +/// is optional and frequently , so it cannot safely be used as an identity key. +/// This is not rendering data: renderers must continue to read only +/// //// +/// / and must ignore this field. +/// Defaults to , which preserves the behavior of every pre-existing call site. +/// public sealed record LayoutPort( double CentreX, double CentreY, PortSide Side, string? ExternalLabel, string? InternalLabel = null, - double MaxLabelWidth = double.PositiveInfinity) : LayoutNode; + double MaxLabelWidth = double.PositiveInfinity, + LayoutGraphPort? SourcePort = null) : LayoutNode; diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs index 6424121..9334423 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs @@ -105,7 +105,7 @@ public void Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector() var composed = new[] { aBox, bBox }; var indexOf = new Dictionary { [a] = 0, [b] = 1 }; - var anchor = new LayoutPort(200, 40, PortSide.Left, "EXT"); + var anchor = new LayoutPort(200, 40, PortSide.Left, "EXT", SourcePort: port); var placedPorts = new List { anchor }; var externalLine = new LayoutLine( [new Point2D(80, 20), new Point2D(200, 40)], @@ -144,6 +144,121 @@ public void Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector() line.Waypoints.Any(wp => wp.X >= cBox.X - 1 && wp.X <= cBox.X + cBox.Width + 1)); } + /// + /// Regression for the boundary-port identity bug (isolated at the resolver level, without the + /// full pipeline): two distinct boundary ports + /// p/q on one container, both with + /// left , each already anchored by the (hand-built) leaf pass and tagged + /// with the correct . Before the fix, + /// matched a boundary port to its leaf anchor by + /// string.Equals on ExternalLabel, so both null-labeled ports would match the same + /// (first) leaf anchor, silently merging both ports' external connectors onto one anchor. This + /// test proves each port resolves independently — its own anchor position, its own external + /// line, its own internal connector — with no cross-wiring, even though ExternalLabel is + /// identical (null) on both. + /// + [Fact] + public void Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity() + { + // Arrange: scope with two siblings A1/A2 and container B owning two boundary ports p/q, both + // with a null ExternalLabel, each delegating to its own child C1/C2. + var scope = new LayoutGraph(); + var a1 = scope.AddNode("A1", 80, 40); + var a2 = scope.AddNode("A2", 80, 40); + var b = scope.AddNode("B", 120, 120); + var p = b.Ports.AddPort("p"); + p.InternalLabel = "P_IN"; + var q = b.Ports.AddPort("q"); + q.InternalLabel = "Q_IN"; + var c1 = b.Children.AddNode("C1", 80, 40); + var c2 = b.Children.AddNode("C2", 80, 40); + scope.AddEdge("a1-p", a1, p); + scope.AddEdge("a2-q", a2, q); + b.Children.AddEdge("p-c1", p, c1); + b.Children.AddEdge("q-c2", q, c2); + + var boundaryPorts = HierarchyMergeRegionBuilder.Collect(scope); + Assert.Equal(2, boundaryPorts.Count); + + // Composed geometry: A1/A2 to the left; B to the right; C1/C2 stacked inside B; the leaf pass's + // two anchors on B's left face, correctly tagged with their originating SourcePort. + var a1Box = Box(0, 0, 80, 40, "A1", []); + var a2Box = Box(0, 60, 80, 40, "A2", []); + var c1Box = Box(220, 20, 80, 40, "C1", []); + var c2Box = Box(220, 80, 80, 40, "C2", []); + var bBox = Box(200, 0, 120, 120, "B", [c1Box, c2Box]); + var composed = new[] { a1Box, a2Box, bBox }; + var indexOf = new Dictionary { [a1] = 0, [a2] = 1, [b] = 2 }; + + var pAnchor = new LayoutPort(200, 20, PortSide.Left, null, SourcePort: p); + var qAnchor = new LayoutPort(200, 100, PortSide.Left, null, SourcePort: q); + var placedPorts = new List { pAnchor, qAnchor }; + var a1Line = new LayoutLine( + [new Point2D(80, 20), new Point2D(200, 20)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + null); + var a2Line = new LayoutLine( + [new Point2D(80, 80), new Point2D(200, 100)], + EndMarkerStyle.None, + EndMarkerStyle.FilledArrow, + LineStyle.Solid, + null); + var placedLines = new List { a1Line, a2Line }; + + // Act + var resolution = BoundaryPortResolver.Resolve( + scope, + LayoutDirection.Right, + boundaryPorts, + composed, + indexOf, + placedPorts, + placedLines); + + // Assert: two distinct anchors at their own original positions, not collapsed onto one. + Assert.Equal(2, resolution.Ports.Count); + var pEmitted = Assert.Single(resolution.Ports, port => port.InternalLabel == "P_IN"); + var qEmitted = Assert.Single(resolution.Ports, port => port.InternalLabel == "Q_IN"); + Assert.Equal(200, pEmitted.CentreX, 3); + Assert.Equal(20, pEmitted.CentreY, 3); + Assert.Equal(200, qEmitted.CentreX, 3); + Assert.Equal(100, qEmitted.CentreY, 3); + + // p's anchor: A1's external line still ends there, and its own internal connector reaches C1 — + // but A2's external line must NOT have been re-terminated onto it. + var pPoint = new Point2D(pEmitted.CentreX, pEmitted.CentreY); + var qPoint = new Point2D(qEmitted.CentreX, qEmitted.CentreY); + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], pPoint) && + Same(line.Waypoints[0], new Point2D(80, 20))); + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], pPoint) && + line.Waypoints.Any(wp => wp.X >= c1Box.X - 1 && wp.X <= c1Box.X + c1Box.Width + 1)); + Assert.DoesNotContain( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], pPoint) && + Same(line.Waypoints[0], new Point2D(80, 80))); + + // q's anchor: A2's external line still ends there, and its own internal connector reaches C2 — + // but A1's external line must NOT have been re-terminated onto it. + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], qPoint) && + Same(line.Waypoints[0], new Point2D(80, 80))); + Assert.Contains( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], qPoint) && + line.Waypoints.Any(wp => wp.X >= c2Box.X - 1 && wp.X <= c2Box.X + c2Box.Width + 1)); + Assert.DoesNotContain( + resolution.Lines, + line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], qPoint) && + Same(line.Waypoints[0], new Point2D(80, 20))); + } + /// /// Builds a with the minimum shape metadata for a resolver test. /// diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index 1f7428e..efbc5a1 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -786,6 +786,165 @@ public void Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors() } } + /// + /// Regression for the boundary-port identity bug: two independent boundary ports P and + /// Q on one container B both leave + /// (the common, default case for a boundary port that has no cosmetic + /// external label). Before the fix, BoundaryPortResolver matched a boundary port to its + /// leaf-placed anchor by string.Equals on ExternalLabel, so both null-labeled + /// ports collapsed onto the same match: one anchor silently absorbed both ports' external + /// connectors while the other anchor was left with none. This test proves each anchor's + /// external connector traces back to its own true external source (not just that two anchors + /// exist), by identifying each anchor via its internal delegation connector's destination + /// (C1 vs C2) and then asserting that anchor's external connector reaches its own + /// sibling (A1 vs A2) and no other. + /// + [Fact] + public void Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance() + { + // Arrange: container B owns two boundary ports, both with a null ExternalLabel, distinguished + // only by InternalLabel and by which sibling/child each is wired to. + var graph = new LayoutGraph(); + var a1 = graph.AddNode("A1", 80, 40); + a1.Label = "A1"; + var a2 = graph.AddNode("A2", 80, 40); + a2.Label = "A2"; + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var p = b.Ports.AddPort("p"); + p.InternalLabel = "P_IN"; + var q = b.Ports.AddPort("q"); + q.InternalLabel = "Q_IN"; + var c1 = b.Children.AddNode("C1", 80, 40); + c1.Label = "C1"; + var c2 = b.Children.AddNode("C2", 80, 40); + c2.Label = "C2"; + + graph.AddEdge("a1-p", a1, p); + graph.AddEdge("a2-q", a2, q); + b.Children.AddEdge("p-c1", p, c1); + b.Children.AddEdge("q-c2", q, c2); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + AssertConnectorProvenancePreserved(tree, "A1", "C1", "A2", "C2"); + } + + /// + /// Same regression as + /// , + /// but covering the finding's "share an ExternalLabel" wording literally: both boundary + /// ports carry the identical non-null ExternalLabel "SAME" rather than both leaving it + /// null. + /// + [Fact] + public void Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance() + { + // Arrange: container B owns two boundary ports sharing one identical, non-null ExternalLabel. + var graph = new LayoutGraph(); + var a1 = graph.AddNode("A1", 80, 40); + a1.Label = "A1"; + var a2 = graph.AddNode("A2", 80, 40); + a2.Label = "A2"; + var b = graph.AddNode("B", 10, 10); + b.Label = "B"; + var p = b.Ports.AddPort("p"); + p.ExternalLabel = "SAME"; + p.InternalLabel = "P_IN"; + var q = b.Ports.AddPort("q"); + q.ExternalLabel = "SAME"; + q.InternalLabel = "Q_IN"; + var c1 = b.Children.AddNode("C1", 80, 40); + c1.Label = "C1"; + var c2 = b.Children.AddNode("C2", 80, 40); + c2.Label = "C2"; + + graph.AddEdge("a1-p", a1, p); + graph.AddEdge("a2-q", a2, q); + b.Children.AddEdge("p-c1", p, c1); + b.Children.AddEdge("q-c2", q, c2); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + AssertConnectorProvenancePreserved(tree, "A1", "C1", "A2", "C2"); + } + + /// + /// Shared assertion helper for the two connector-provenance regression tests above: given a + /// resolved tree with exactly two boundary-port anchors on container B, identifies each + /// anchor by which composed child box its internal delegation connector reaches, then asserts + /// that anchor's external connector reaches its own corresponding sibling box and no other, and + /// that the two anchors are not collapsed onto the same point. + /// + private static void AssertConnectorProvenancePreserved( + LayoutTree tree, + string firstSiblingLabel, + string firstChildLabel, + string secondSiblingLabel, + string secondChildLabel) + { + var emitted = tree.Nodes.OfType().ToList(); + Assert.Equal(2, emitted.Count); + + var containerBox = tree.Nodes.OfType().Single(box => box.Label == "B"); + foreach (var port in emitted) + { + Assert.True( + OnBoxBoundary(port.CentreX, port.CentreY, containerBox), + "A boundary-port anchor does not lie on container B's boundary."); + } + + // The two anchors must not be collapsed onto one point. + Assert.False( + SamePoint( + new Point2D(emitted[0].CentreX, emitted[0].CentreY), + new Point2D(emitted[1].CentreX, emitted[1].CentreY)), + "The two independent boundary-port anchors collapsed onto the same point."); + + var firstChildBox = containerBox.Children.OfType().Single(box => box.Label == firstChildLabel); + var secondChildBox = containerBox.Children.OfType().Single(box => box.Label == secondChildLabel); + var firstSiblingBox = tree.Nodes.OfType().Single(box => box.Label == firstSiblingLabel); + var secondSiblingBox = tree.Nodes.OfType().Single(box => box.Label == secondSiblingLabel); + + var lines = tree.Nodes.OfType().ToList(); + + foreach (var port in emitted) + { + var anchor = new Point2D(port.CentreX, port.CentreY); + bool ReachesAnchor(LayoutLine line) => + line.Waypoints.Count > 0 && + (SamePoint(line.Waypoints[0], anchor) || SamePoint(line.Waypoints[^1], anchor)); + + bool ReachesBox(LayoutLine line, LayoutBox box) => + ReachesAnchor(line) && + line.Waypoints.Any(wp => + wp.X >= box.X - 1.0 && wp.X <= box.X + box.Width + 1.0 && + wp.Y >= box.Y - 1.0 && wp.Y <= box.Y + box.Height + 1.0); + + // Determine which logical port this anchor is by its internal delegation connector's + // destination — the actual "provenance" check the prior label/count-only test lacked. + var isFirst = lines.Any(line => ReachesBox(line, firstChildBox)); + var isSecond = lines.Any(line => ReachesBox(line, secondChildBox)); + Assert.True(isFirst ^ isSecond, "Anchor must reach exactly one of the two internal children."); + + var (ownChild, ownSibling, otherSibling) = isFirst + ? (firstChildBox, firstSiblingBox, secondSiblingBox) + : (secondChildBox, secondSiblingBox, firstSiblingBox); + + Assert.True( + lines.Any(line => ReachesBox(line, ownChild)), + "Anchor's internal delegation connector does not reach its own child."); + Assert.True( + lines.Any(line => ReachesBox(line, ownSibling)), + "Anchor's external connector does not reach its own true external sibling."); + Assert.False( + lines.Any(line => ReachesBox(line, otherSibling)), + "Anchor's external connector incorrectly reaches the OTHER port's sibling (cross-wiring bug)."); + } + } + /// /// Proves that a port edge which genuinely crosses a container boundary in a shape Stage 1 does /// not support — a named port owned by a plain (non-container) node, with an edge From 6b885ae179e20f3dd23ae5b44d3575ad53cee613 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 08:31:07 -0400 Subject: [PATCH 11/23] feat(layout): add MergeRegionGraphAssembler for hierarchical boundary-port assembly --- .../Layered/MergeRegionGraphAssembler.cs | 429 ++++++++++++++++++ .../Layered/MergeRegionGraphAssemblerTests.cs | 166 +++++++ 2 files changed, 595 insertions(+) create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionGraphAssemblerTests.cs diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs new file mode 100644 index 0000000..981aa82 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs @@ -0,0 +1,429 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// One nesting level's contribution to an assembled merge region: the direct-member nodes and ordinary +/// (non-boundary-crossing) edges of a single container scope, plus the nested child levels contributed +/// by every boundary-port container discovered directly in that scope. +/// +/// +/// +/// A level mirrors the full leaf-level flattening decision: is the scope's +/// entire direct-member node set — boundary-port containers and ordinary interior nodes +/// alike — never a filtered "boundary skeleton" subset, so an ordinary interior node always +/// participates in the combined pass. is the scope's own edges with the +/// boundary-crossing edges removed; a boundary-crossing edge is one that references either a +/// boundary port discovered in this scope or the parent boundary port delegating into it, and is +/// represented instead as a dummy by . +/// +/// +/// Each level keeps its own local because a container's interior is +/// spatially nested inside the container's own box, not sequential with the outer scope's layer +/// progression. is depth-unbounded: it holds one entry per boundary-port +/// container discovered in this scope, each carrying its own recursively-assembled +/// , to whatever depth the discovery layer reports. +/// +/// +/// The container scope this level's nodes and edges were drawn from. +/// +/// This level's direct-member nodes, in graph (insertion) order; the complete interior node set, not a +/// boundary-port subset. +/// +/// +/// This level's own edges excluding boundary-crossing edges (which become +/// dummy edges), in graph order. +/// +/// Maps each node in to its index into that list, local to this level. +/// +/// The nested levels, one per boundary-port container discovered directly in this scope; index-aligned +/// with . +/// +/// +/// The boundary ports discovered directly in this scope, in discovery order; index-aligned with +/// (BoundaryPorts[i].Container is Children[i].Container). +/// +/// +/// The parent boundary port whose internal (delegation) edges cross into this scope, or +/// for the outermost (root) level. +/// +/// +/// The effective bounding-box size lookup used when translating this level's nodes into +/// s; nodes absent from the lookup fall back to their own +/// /. +/// +internal sealed record MergeRegionLevel( + LayoutGraph Scope, + IReadOnlyList Nodes, + IReadOnlyList Edges, + IReadOnlyDictionary NodeIndex, + IReadOnlyList<(LayoutGraphNode Container, int NodeIndex, MergeRegionLevel Child)> Children, + IReadOnlyList BoundaryPorts, + BoundaryPort? IncomingBoundary, + IReadOnlyDictionary EffectiveSize); + +/// +/// The fully assembled combined graph spanning every nesting level of one merge region: the outermost +/// level and the flattened union of every boundary port discovered at any depth. +/// +/// The outermost nesting level; its reach every deeper level. +/// +/// Every boundary port discovered at any nesting level of the region, in top-down discovery order, for +/// the decomposition step's lookup. +/// +internal sealed record AssembledMergeRegion( + MergeRegionLevel Root, + IReadOnlyList AllBoundaryPorts); + +/// +/// Assembles the combined, hierarchy-aware node/edge model for one merge region from the boundary ports +/// discovered in a scope, so the recursive layered +/// pipeline can lay every nesting level out in one combined pass rather than by post-hoc reconciliation. +/// +/// +/// +/// The assembler realizes the two settled architecture decisions. Full leaf-level flattening: +/// a boundary-port container's entire interior — every node and edge of its child scope, boundary- +/// related or not — is admitted as a nested , so an ordinary interior +/// node participates in the same combined pass as the outer scope. Arbitrary chain depth: +/// recurses into every boundary-port container's child scope, rediscovering +/// that scope's own boundary ports with , to +/// whatever depth the discovery layer reports, never capping the delegation chain. +/// +/// +/// Discovery is delegated verbatim to ; this type only +/// assembles the combined graph from that discovery, keeping each nesting level's local +/// node indexing distinct because a container's interior is nested inside its own box. +/// +/// +internal static class MergeRegionGraphAssembler +{ + /// + /// Assembles the combined, hierarchy-aware node/edge model for the merge region rooted at + /// , given the boundary ports discovered in that scope. + /// + /// + /// Recurses into every boundary-port container's full child scope (full leaf-level flattening), + /// to arbitrary depth, building one per nesting level rather than + /// a single flat node list. The deeper levels' boundary ports are rediscovered with + /// as the recursion descends. + /// + /// The outermost container scope of the merge region. + /// The boundary ports already discovered directly in . + /// + /// The effective bounding-box size lookup for the region's nodes; a node absent from the lookup + /// falls back to its own /. + /// + /// The assembled merge region spanning every nesting level. + /// + /// Thrown when , , or + /// is . + /// + public static AssembledMergeRegion Assemble( + LayoutGraph scope, + IReadOnlyList boundaryPorts, + IReadOnlyDictionary effectiveSize) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(boundaryPorts); + ArgumentNullException.ThrowIfNull(effectiveSize); + + // Accumulate every boundary port discovered at any depth for the decomposition step's lookup, + // in top-down discovery order (this level's ports precede its descendants'). + var allBoundaryPorts = new List(); + var root = BuildLevel(scope, boundaryPorts, incomingBoundary: null, effectiveSize, allBoundaryPorts); + return new AssembledMergeRegion(root, allBoundaryPorts); + } + + /// + /// Builds the per-level for one : real + /// interior nodes translated to s, ordinary interior edges to + /// s, and every boundary-crossing edge replaced by a zero-size + /// hierarchy-crossing dummy node with its own incident s. + /// + /// + /// Package-private so the recursive CrossingMinimizer/LayerAssigner entry points + /// (later stages) can rebuild each nested level's graph on demand. The crossing dummy wiring is + /// the input-graph precedent set by : a boundary + /// port becomes a zero-size dummy that the pipeline lays out like any other node. The + /// descriptor itself lives on and is + /// populated by the pipeline's augmentation stages, so it is not carried on the input + /// here. + /// + /// The nesting level to build a layered graph for. + /// The flow direction the level is laid out along; defaults to . + /// The per-level layered graph, ready to feed the recursive pipeline. + /// Thrown when is . + internal static LayeredGraph BuildLevelGraph(MergeRegionLevel level, LayoutDirection direction = LayoutDirection.Right) + { + ArgumentNullException.ThrowIfNull(level); + + // Real interior nodes occupy indices 0..N-1, preserving this level's local node order. + var nodes = new List(level.Nodes.Count); + foreach (var node in level.Nodes) + { + var (width, height) = ResolveSize(level, node); + nodes.Add(new LayerNode( + width, + height, + node.Shape, + node.RoundedCornerRadius, + node.FolderTabWidth, + node.FolderTabHeight, + node.Label, + RealWidth: width, + RealHeight: height)); + } + + var edges = new List(); + + // Ordinary interior edges connect two direct-member nodes of this level. + foreach (var edge in level.Edges) + { + var source = ResolveMemberIndex(level, edge.Source); + var target = ResolveMemberIndex(level, edge.Target); + if (source >= 0 && target >= 0) + { + edges.Add(new LayerEdge(source, target)); + } + } + + // The incoming crossing: the parent boundary port delegates inward, so a single zero-size dummy + // (its internal face) feeds each interior delegation target. + if (level.IncomingBoundary is { } incoming) + { + AddIncomingCrossing(level, incoming, nodes, edges); + } + + // The outgoing crossings: each boundary-port container's external approach edges feed a zero-size + // dummy (its external face) that then leads into the container's own box. + foreach (var boundary in level.BoundaryPorts) + { + AddOutgoingCrossing(level, boundary, nodes, edges); + } + + return new LayeredGraph(nodes, edges, direction); + } + + /// + /// Recursively builds one for and every + /// boundary-port container nested within it, appending each level's boundary ports to + /// in top-down order. + /// + /// The container scope this level is drawn from. + /// The boundary ports discovered directly in . + /// The parent boundary port delegating into this scope, or at the root. + /// The effective size lookup threaded to every level. + /// The accumulating flattened boundary-port list. + /// The assembled level for . + private static MergeRegionLevel BuildLevel( + LayoutGraph scope, + IReadOnlyList boundaryPorts, + BoundaryPort? incomingBoundary, + IReadOnlyDictionary effectiveSize, + List allBoundaryPorts) + { + // Full leaf-level flattening: the level keeps the scope's entire direct-member node set. + var nodes = scope.Nodes.ToList(); + var nodeIndex = new Dictionary(); + for (var i = 0; i < nodes.Count; i++) + { + nodeIndex[nodes[i]] = i; + } + + // Boundary-crossing edges reference either a boundary port discovered here or the parent + // boundary port delegating in; they are represented as hierarchy crossings, so they are removed + // from the level's ordinary edge set. + var boundaryPortSet = new HashSet(); + foreach (var boundary in boundaryPorts) + { + boundaryPortSet.Add(boundary.Port); + } + + if (incomingBoundary is { } incoming) + { + boundaryPortSet.Add(incoming.Port); + } + + var edges = scope.Edges + .Where(edge => !ReferencesBoundaryPort(edge, boundaryPortSet)) + .ToList(); + + // This level's own boundary ports precede its descendants' in the flattened lookup. + allBoundaryPorts.AddRange(boundaryPorts); + + // Recurse into every boundary-port container's full child scope, rediscovering that scope's own + // boundary ports so a delegation chain of any depth is assembled level by level. + var children = new List<(LayoutGraphNode Container, int NodeIndex, MergeRegionLevel Child)>(); + foreach (var boundary in boundaryPorts) + { + var container = boundary.Container; + var childScope = container.Children; + var childBoundaries = HierarchyMergeRegionBuilder.Collect(childScope); + var childLevel = BuildLevel(childScope, childBoundaries, boundary, effectiveSize, allBoundaryPorts); + children.Add((container, nodeIndex[container], childLevel)); + } + + return new MergeRegionLevel( + scope, + nodes, + edges, + nodeIndex, + children, + boundaryPorts, + incomingBoundary, + effectiveSize); + } + + /// + /// Appends a single zero-size hierarchy-crossing dummy for 's internal + /// face and wires it to each interior delegation target reachable in . + /// + /// The level whose interior receives the delegation. + /// The parent boundary port delegating into the level. + /// The working layer-node list, appended to in place. + /// The working layer-edge list, appended to in place. + private static void AddIncomingCrossing( + MergeRegionLevel level, + BoundaryPort incoming, + List nodes, + List edges) + { + var dummyIndex = nodes.Count; + var wired = false; + foreach (var edge in incoming.InternalEdges) + { + var targetEndpoint = OtherEndpoint(edge, incoming.Port); + var target = ResolveMemberIndex(level, targetEndpoint); + if (target < 0) + { + continue; + } + + if (!wired) + { + nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); + wired = true; + } + + edges.Add(new LayerEdge(dummyIndex, target)); + } + } + + /// + /// Appends a single zero-size hierarchy-crossing dummy for 's external + /// face, wires each in-scope external approach edge to it, and leads the dummy into the container box. + /// + /// The level owning the boundary-port container. + /// The boundary port whose external approach is being wired. + /// The working layer-node list, appended to in place. + /// The working layer-edge list, appended to in place. + private static void AddOutgoingCrossing( + MergeRegionLevel level, + BoundaryPort boundary, + List nodes, + List edges) + { + if (!level.NodeIndex.TryGetValue(boundary.Container, out var containerIndex)) + { + return; + } + + var dummyIndex = nodes.Count; + var wired = false; + foreach (var edge in boundary.ExternalEdges) + { + var sourceEndpoint = OtherEndpoint(edge, boundary.Port); + var source = ResolveMemberIndex(level, sourceEndpoint); + if (source < 0) + { + // The approach originates outside this scope (it is the parent's incoming crossing), + // so it is represented at that outer level rather than duplicated here. + continue; + } + + if (!wired) + { + nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); + edges.Add(new LayerEdge(dummyIndex, containerIndex)); + wired = true; + } + + edges.Add(new LayerEdge(source, dummyIndex)); + } + } + + /// + /// Resolves the effective bounding-box size of for + /// , falling back to the node's own dimensions when it is absent from the + /// level's effective-size lookup. + /// + /// The level whose effective-size lookup is consulted. + /// The node whose size is required. + /// The effective width and height to lay the node out with. + private static (double Width, double Height) ResolveSize(MergeRegionLevel level, LayoutGraphNode node) + { + return level.EffectiveSize.TryGetValue(node, out var size) + ? size + : (node.Width, node.Height); + } + + /// + /// Resolves to the index of the direct-member node of + /// it belongs to: the node itself when it is a member, or the member node + /// that owns the endpoint port; otherwise -1 when the endpoint lies outside this level. + /// + /// The level whose direct members are searched. + /// The edge endpoint (a node or a port) to resolve. + /// The direct-member node index, or -1 when the endpoint is not a member of this level. + private static int ResolveMemberIndex(MergeRegionLevel level, ILayoutConnectable endpoint) + { + switch (endpoint) + { + case LayoutGraphNode node when level.NodeIndex.TryGetValue(node, out var nodeIndex): + return nodeIndex; + + case LayoutGraphPort port: + for (var i = 0; i < level.Nodes.Count; i++) + { + var owner = level.Nodes[i]; + if (owner.HasPorts && owner.Ports.Ports.Any(p => ReferenceEquals(p, port))) + { + return i; + } + } + + return -1; + + default: + return -1; + } + } + + /// + /// Returns whether references any port in + /// at either endpoint, marking it a boundary-crossing edge. + /// + /// The edge to test. + /// The set of boundary ports relevant to the current scope. + /// when either endpoint is a boundary port; otherwise . + private static bool ReferencesBoundaryPort(LayoutGraphEdge edge, HashSet boundaryPortSet) + { + return (edge.Source is LayoutGraphPort source && boundaryPortSet.Contains(source)) + || (edge.Target is LayoutGraphPort target && boundaryPortSet.Contains(target)); + } + + /// + /// Returns the endpoint of that is not : the source + /// when the port is the target, otherwise the target. + /// + /// The boundary-crossing edge. + /// The boundary port whose opposite endpoint is required. + /// The edge endpoint opposite . + private static ILayoutConnectable OtherEndpoint(LayoutGraphEdge edge, LayoutGraphPort port) + { + return ReferenceEquals(edge.Target, port) ? edge.Source : edge.Target; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionGraphAssemblerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionGraphAssemblerTests.cs new file mode 100644 index 0000000..2ce5b6c --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionGraphAssemblerTests.cs @@ -0,0 +1,166 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for : the structural assembly of a merge region into +/// nested s, enforcing full leaf-level flattening (a container's entire +/// interior participates, not a boundary skeleton) and arbitrary delegation-chain depth. +/// +public sealed class MergeRegionGraphAssemblerTests +{ + /// + /// A single boundary-port container produces exactly one nested child level, and that level + /// retains the container's full interior: every interior node in + /// and every ordinary interior edge in , with the delegation + /// edge captured as the child level's incoming boundary crossing. + /// + [Fact] + public void MergeRegionGraphAssembler_Assemble_SingleLevelBoundaryPort_ProducesOneChildLevel() + { + // Arrange: scope { A, B(port p) }, external A->p; B.Children { C, D } with delegation p->C and + // an ordinary interior edge C->D. + var scope = new LayoutGraph(); + var a = scope.AddNode("A", 80, 40); + var b = scope.AddNode("B", 10, 10); + var p = b.Ports.AddPort("p"); + var c = b.Children.AddNode("C", 80, 40); + var d = b.Children.AddNode("D", 80, 40); + scope.AddEdge("a-p", a, p); + var delegation = b.Children.AddEdge("p-c", p, c); + var interior = b.Children.AddEdge("c-d", c, d); + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Act + var region = MergeRegionGraphAssembler.Assemble(scope, boundaries, EmptySize()); + + // Assert: exactly one child level for the single boundary-port container B. + var child = Assert.Single(region.Root.Children); + Assert.Same(b, child.Container); + Assert.Same(b.Children, child.Child.Scope); + + // Assert: full interior node set is retained (both C and D, not a boundary skeleton). + Assert.Equal(new[] { c, d }, child.Child.Nodes); + + // Assert: the ordinary interior edge is retained; the delegation edge is not an ordinary edge. + Assert.Contains(interior, child.Child.Edges); + Assert.DoesNotContain(delegation, child.Child.Edges); + + // Assert: the delegation edge is captured as the child level's incoming boundary crossing. + Assert.NotNull(child.Child.IncomingBoundary); + Assert.Same(p, child.Child.IncomingBoundary!.Port); + Assert.Contains(delegation, child.Child.IncomingBoundary.InternalEdges); + } + + /// + /// A three-level delegation chain — container A's boundary port delegates to nested container B's + /// boundary port, which delegates to nested container C's boundary port, which delegates to a leaf + /// child — assembles three nested s, each retaining its own interior + /// nodes and edges, proving the assembler recurses to arbitrary depth. + /// + [Fact] + public void MergeRegionGraphAssembler_Assemble_ThreeLevelChain_RecursesToDepthThree() + { + // Arrange: root { Outer, A(port pa) }; external Outer->pa. + var root = new LayoutGraph(); + var outer = root.AddNode("Outer", 80, 40); + var a = root.AddNode("A", 10, 10); + var pa = a.Ports.AddPort("pa"); + root.AddEdge("outer-pa", outer, pa); + + // A.Children { B(port pb), M1 }; delegation pa->pb; ordinary interior M1 alongside the container. + var b = a.Children.AddNode("B", 10, 10); + var pb = b.Ports.AddPort("pb"); + var m1 = a.Children.AddNode("M1", 30, 30); + a.Children.AddEdge("pa-pb", pa, pb); + + // B.Children { C(port pc), M2 }; delegation pb->pc; ordinary interior M2. + var c = b.Children.AddNode("C", 10, 10); + var pc = c.Ports.AddPort("pc"); + var m2 = b.Children.AddNode("M2", 30, 30); + b.Children.AddEdge("pb-pc", pb, pc); + + // C.Children { Leaf, M3 }; delegation pc->Leaf; ordinary interior M3. + var leaf = c.Children.AddNode("Leaf", 80, 40); + var m3 = c.Children.AddNode("M3", 30, 30); + c.Children.AddEdge("pc-leaf", pc, leaf); + + var boundaries = HierarchyMergeRegionBuilder.Collect(root); + + // Act + var region = MergeRegionGraphAssembler.Assemble(root, boundaries, EmptySize()); + + // Assert: level 1 (root) retains its own interior and nests container A. + var level1 = region.Root; + Assert.Same(root, level1.Scope); + var childA = Assert.Single(level1.Children); + Assert.Same(a, childA.Container); + + // Assert: level 2 (A.Children) retains ordinary interior node M1 and nests container B. + var level2 = childA.Child; + Assert.Same(a.Children, level2.Scope); + Assert.Contains(m1, level2.Nodes); + var childB = Assert.Single(level2.Children); + Assert.Same(b, childB.Container); + + // Assert: level 3 (B.Children) retains ordinary interior node M2 and nests container C. + var level3 = childB.Child; + Assert.Same(b.Children, level3.Scope); + Assert.Contains(m2, level3.Nodes); + var childC = Assert.Single(level3.Children); + Assert.Same(c, childC.Container); + + // Assert: level 4 (C.Children) is the innermost — it retains the leaf and interior M3 and nests + // no further container, confirming exactly three delegation levels below the root scope. + var level4 = childC.Child; + Assert.Same(c.Children, level4.Scope); + Assert.Contains(leaf, level4.Nodes); + Assert.Contains(m3, level4.Nodes); + Assert.Empty(level4.Children); + } + + /// + /// An ordinary (non-boundary) interior node of a merge-region container appears in the assembled + /// level's list — the full-flattening guardrail that the + /// interior is not filtered down to a boundary-port skeleton subset. + /// + [Fact] + public void MergeRegionGraphAssembler_Assemble_NonBoundaryInteriorNode_IncludedInFullFlattening() + { + // Arrange: scope { A, B(port p) }, external A->p; B.Children { Target, Bystander } where the + // delegation p->Target reaches Target and Bystander is an ordinary interior node touched by no + // boundary crossing at all. + var scope = new LayoutGraph(); + var a = scope.AddNode("A", 80, 40); + var b = scope.AddNode("B", 10, 10); + var p = b.Ports.AddPort("p"); + var target = b.Children.AddNode("Target", 80, 40); + var bystander = b.Children.AddNode("Bystander", 60, 30); + scope.AddEdge("a-p", a, p); + b.Children.AddEdge("p-target", p, target); + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + + // Act + var region = MergeRegionGraphAssembler.Assemble(scope, boundaries, EmptySize()); + + // Assert: the ordinary, boundary-unrelated interior node is present in the flattened level. + var child = Assert.Single(region.Root.Children); + Assert.Contains(bystander, child.Child.Nodes); + Assert.True(child.Child.NodeIndex.ContainsKey(bystander)); + } + + /// + /// Creates an empty effective-size lookup, so every node falls back to its own declared + /// dimensions during assembly. + /// + /// An empty node-to-size lookup. + private static IReadOnlyDictionary EmptySize() + { + return new Dictionary(); + } +} From fb3820ab54706a64cf85265ce2ba2678a233ed92 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 09:11:55 -0400 Subject: [PATCH 12/23] feat(layout): recursive hierarchy-aware CrossingMinimizer/LayerAssigner + pipeline --- .../Engine/Layered/CrossingMinimizer.cs | 267 ++++++++++++++++++ .../Engine/Layered/LayerAssigner.cs | 33 +++ .../Engine/Layered/LayeredGraph.cs | 28 +- .../Engine/Layered/LayeredLayoutPipeline.cs | 143 +++++++++- .../Engine/Layered/LongEdgeSplitter.cs | 18 +- .../Layered/MergeRegionGraphAssembler.cs | 154 ++++++++-- .../CrossingMinimizerRecursionTests.cs | 214 ++++++++++++++ .../Engine/Layered/LongEdgeSplitterTests.cs | 36 +++ 8 files changed, 847 insertions(+), 46 deletions(-) create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerRecursionTests.cs diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs index f805ef9..ff33b96 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/CrossingMinimizer.cs @@ -18,6 +18,273 @@ public void Apply(LayeredGraph graph) OrderLayersAug(graph.Groups, graph.AugNodes.Count, graph.AugEdges); } + /// + /// Minimizes crossings across every nesting level of an assembled merge region with hierarchical + /// port-order propagation, mirroring ELK's per-level sweep-with-recursion, and sets each level's + /// to the resolved ordering. + /// + /// + /// + /// Each level is minimized over its own so its layers stay local to + /// the container's own box; the levels are coupled only through their hierarchy-crossing dummies, + /// which pair by identity — the + /// crossing in a parent level and the crossing in the + /// matching child level stand for the same boundary port. + /// + /// + /// The recursion is a genuine two-direction hierarchical sweep. Up-sweep: every child + /// subtree is resolved first (innermost first), and the child's resolved incoming-crossing order + /// seeds this level's matching outgoing crossings, so a child order propagates upward whenever + /// this level's own external pressure is neutral. Down-sweep: this level's resolved + /// outgoing-crossing order — decided under outer-scope crossing pressure — is then fed back down + /// as a fixed order for each child's incoming crossings and the child is re-minimized, so an + /// ordinary (non-boundary) interior node genuinely reorders under outer-scope pressure rather + /// than only bottom-up. The flat entry point is unchanged and still governs + /// every single-level graph. + /// + /// + /// The nesting level whose subtree is minimized. + /// The lookup from each level to its assembled layered graph and crossing bookkeeping. + /// Thrown when or is . + internal void MinimizeCrossingsRecursive( + MergeRegionLevel level, + IReadOnlyDictionary levels) + { + ArgumentNullException.ThrowIfNull(level); + ArgumentNullException.ThrowIfNull(levels); + + // Up-sweep: resolve every child subtree first, then read each child's resolved incoming-crossing + // (Internal face) order as this level's preferred order for the matching outgoing crossings. + var childPreference = new Dictionary(); + var rank = 0; + foreach (var (_, _, child) in level.Children) + { + MinimizeCrossingsRecursive(child, levels); + foreach (var port in ReadCrossingOrder(levels[child], HierarchyCrossingFace.Internal)) + { + childPreference[port] = rank++; + } + } + + // Order this level, seeding its outgoing crossings with the children's preferred order so a + // resolved child order propagates upward when this level's own external pressure does not decide. + var levelGraph = levels[level]; + OrderWithSeed(levelGraph, HierarchyCrossingFace.External, childPreference); + + // Down-sweep: feed this level's resolved outgoing-crossing order back down into each child as a + // fixed order for that child's incoming crossings, re-minimizing the child subtree so an ordinary + // interior node reorders under outer-scope pressure. + var externalOrder = ReadCrossingRanks(levelGraph, HierarchyCrossingFace.External); + foreach (var (_, _, child) in level.Children) + { + PropagateDown(child, levels, externalOrder); + } + } + + /// + /// Re-minimizes 's subtree with its incoming crossings pinned to + /// (the parent's resolved outgoing order), then propagates the + /// resulting outgoing order further down into every nested child. + /// + /// The child level being re-minimized under outer-scope pressure. + /// The lookup from each level to its assembled layered graph. + /// The fixed cross-axis rank per boundary port for this level's incoming crossings. + private void PropagateDown( + MergeRegionLevel level, + IReadOnlyDictionary levels, + IReadOnlyDictionary pinnedIncoming) + { + var levelGraph = levels[level]; + OrderWithPinnedIncoming(levelGraph, pinnedIncoming); + + var externalOrder = ReadCrossingRanks(levelGraph, HierarchyCrossingFace.External); + foreach (var (_, _, child) in level.Children) + { + PropagateDown(child, levels, externalOrder); + } + } + + /// + /// Groups by layer, seeds the cross-axis order of the crossings on + /// from (a tie-break honored only when this level's + /// own barycenter pressure is neutral), then runs the standard barycenter sweeps. + /// + /// The level graph to order. + /// The crossing face whose initial order is seeded. + /// The preferred cross-axis rank per boundary port; ports absent from it are left in place. + private static void OrderWithSeed( + LevelLayeredGraph levelGraph, + HierarchyCrossingFace face, + IReadOnlyDictionary seed) + { + var graph = levelGraph.Graph; + graph.Groups = GroupByLayerAug(graph.AugNodes); + SeedCrossingOrder(graph.Groups, levelGraph.Crossings, face, seed); + OrderLayersAug(graph.Groups, graph.AugNodes.Count, graph.AugEdges); + } + + /// + /// Groups by layer, pins the cross-axis order of its incoming crossings + /// to , then propagates that fixed order forward through the interior + /// layers only, so the pinned crossings are never reordered by a reverse sweep. + /// + /// The level graph to re-order under a fixed incoming order. + /// The fixed cross-axis rank per boundary port for the incoming crossings. + private static void OrderWithPinnedIncoming( + LevelLayeredGraph levelGraph, + IReadOnlyDictionary pinnedIncoming) + { + var graph = levelGraph.Graph; + graph.Groups = GroupByLayerAug(graph.AugNodes); + SeedCrossingOrder(graph.Groups, levelGraph.Crossings, HierarchyCrossingFace.Internal, pinnedIncoming); + ForwardPropagate(graph.Groups, graph.AugNodes.Count, graph.AugEdges); + } + + /// + /// Returns the boundary ports of the crossings on in their resolved + /// cross-axis order (their position within their layer in 's groups). + /// + /// The already-ordered level graph. + /// The crossing face to read. + /// The boundary ports in cross-axis order. + private static IReadOnlyList ReadCrossingOrder( + LevelLayeredGraph levelGraph, + HierarchyCrossingFace face) + { + var positions = PositionsInLayer(levelGraph.Graph.Groups); + return levelGraph.Crossings + .Where(crossing => crossing.Face == face) + .OrderBy(crossing => positions.TryGetValue(crossing.NodeIndex, out var p) ? p : int.MaxValue) + .Select(crossing => crossing.Boundary.Port) + .ToList(); + } + + /// + /// Returns the crossings on as a lookup from boundary port to resolved + /// cross-axis rank (0-based, in resolved order). + /// + /// The already-ordered level graph. + /// The crossing face to read. + /// A lookup from boundary port to cross-axis rank. + private static IReadOnlyDictionary ReadCrossingRanks( + LevelLayeredGraph levelGraph, + HierarchyCrossingFace face) + { + var order = ReadCrossingOrder(levelGraph, face); + var ranks = new Dictionary(); + for (var i = 0; i < order.Count; i++) + { + ranks[order[i]] = i; + } + + return ranks; + } + + /// + /// Reorders, in place, the crossings on within each layer they occupy so + /// they appear in ascending rank at the slot positions they already hold, + /// leaving every non-crossing node fixed. + /// + /// The per-layer ordered index lists to seed. + /// The level's crossing metadata. + /// The crossing face to reorder. + /// The preferred cross-axis rank per boundary port. + private static void SeedCrossingOrder( + List> groups, + IReadOnlyList crossings, + HierarchyCrossingFace face, + IReadOnlyDictionary seed) + { + // Map each seeded crossing node index to its desired rank. + var desiredRank = new Dictionary(); + foreach (var crossing in crossings) + { + if (crossing.Face == face && seed.TryGetValue(crossing.Boundary.Port, out var r)) + { + desiredRank[crossing.NodeIndex] = r; + } + } + + if (desiredRank.Count == 0) + { + return; + } + + foreach (var layer in groups) + { + // The slot positions this layer's seeded crossings occupy, and the crossing node indices. + var slots = new List(); + var seededNodes = new List(); + for (var i = 0; i < layer.Count; i++) + { + if (desiredRank.ContainsKey(layer[i])) + { + slots.Add(i); + seededNodes.Add(layer[i]); + } + } + + if (seededNodes.Count <= 1) + { + continue; + } + + // Sort the crossing nodes by desired rank and write them back into the same slot positions. + seededNodes.Sort((a, b) => desiredRank[a].CompareTo(desiredRank[b])); + for (var i = 0; i < slots.Count; i++) + { + layer[slots[i]] = seededNodes[i]; + } + } + } + + /// Returns a lookup from augmented-node index to its position within its layer group. + /// The per-layer ordered index lists. + /// A lookup from node index to its cross-axis position within its layer. + private static Dictionary PositionsInLayer(List> groups) + { + var positions = new Dictionary(); + foreach (var layer in groups) + { + for (var i = 0; i < layer.Count; i++) + { + positions[layer[i]] = i; + } + } + + return positions; + } + + /// + /// Orders every interior layer (1..) by the barycenter of its left neighbors, propagating the fixed + /// order of layer 0 rightward without any reverse sweep, so pinned layer-0 crossings stay fixed. + /// + /// The per-layer ordered index lists, ordered in place. + /// The number of augmented nodes. + /// The augmented sub-edges. + private static void ForwardPropagate(List> groups, int numAug, List augEdges) + { + var leftNeighbors = new List[numAug]; + for (var i = 0; i < numAug; i++) + { + leftNeighbors[i] = []; + } + + foreach (var ae in augEdges) + { + leftNeighbors[ae.Target].Add(ae.Source); + } + + // Two forward passes give the interior layers a stable order relative to the pinned layer 0. + for (var pass = 0; pass < 2; pass++) + { + for (var l = 1; l < groups.Count; l++) + { + SortByBarycenter(groups[l], groups[l - 1], leftNeighbors); + } + } + } + /// Groups augmented-node indices by layer. private static List> GroupByLayerAug(List augNodes) { diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs index db81bb7..1a09bd4 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayerAssigner.cs @@ -15,6 +15,39 @@ public void Apply(LayeredGraph graph) graph.NodeLayers = AssignLayers(graph.N, graph.Acyclic); } + /// + /// Assigns level-relative layers to every nesting level of an assembled merge region: each level's + /// interior nodes are laid out by that level's own longest-path computation, starting fresh at + /// layer 0, and never appended to the outer scope's global layer sequence. + /// + /// + /// A merge-region container node is an ordinary node in its parent level's own + /// longest-path sequence; its child level's layers are computed independently over the child + /// level's own graph, so a container's interior stays spatially nested inside the container's own + /// box rather than progressing sequentially with the outer scope. Each level's + /// must already have had run (its + /// populated). The flat entry point is + /// unchanged and still governs every single-level (non-hierarchical) graph. + /// + /// The nesting level whose subtree is assigned layers. + /// The lookup from each level to its assembled layered graph. + /// Thrown when or is . + internal void AssignLayersRecursive( + MergeRegionLevel level, + IReadOnlyDictionary levels) + { + ArgumentNullException.ThrowIfNull(level); + ArgumentNullException.ThrowIfNull(levels); + + var graph = levels[level].Graph; + graph.NodeLayers = AssignLayers(graph.N, graph.Acyclic); + + foreach (var (_, _, child) in level.Children) + { + AssignLayersRecursive(child, levels); + } + } + /// /// Assigns each node to a layer equal to the length of its longest incoming path /// (sources at layer 0, sinks at the maximum layer). diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index 5c02911..4ee0825 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -30,14 +30,15 @@ internal enum HierarchyCrossingFace /// /// /// Generalizes LongEdgeSplitter's zero-size intermediate-layer dummy from "spans layers -/// within one scope" to "spans layers across nested scopes". In the fully-joint pass this mode is -/// designed toward, a hierarchy-crossing dummy would participate in the same layer-assignment, -/// crossing-minimization, and placement pass as ordinary dummies while additionally remembering the -/// originating and which of the container boundary it stands -/// in for. That descriptor is reserved scaffolding: the current recursive handling reaches the same -/// general/transitive external result by reconciliation (see ) -/// and does not yet populate hierarchy-crossing dummies into the augmented graph, so this type is -/// currently exercised only by the ordering primitive's unit tests. +/// within one scope" to "spans layers across nested scopes". A hierarchy-crossing dummy participates +/// in the same layer-assignment, crossing-minimization, and placement stages as an ordinary dummy +/// while additionally remembering the originating and which of +/// the container boundary it stands in for. The recursive layered pipeline +/// (LayeredLayoutPipeline.RunRecursive) is the producer: MergeRegionGraphAssembler +/// seeds one crossing dummy per boundary port and the pipeline tags the corresponding +/// after long-edge splitting, then reads the dummies' placed +/// positions back to propagate a resolved boundary order between nesting levels. The ordering +/// primitive's unit tests exercise the same descriptor. /// /// The originating input-graph boundary port this dummy stands in for. /// Which logical face (external/internal) of the boundary crossing this dummy is. @@ -49,11 +50,12 @@ internal enum HierarchyCrossingFace /// Assigned Sugiyama layer index. /// Whether this node is a zero-size long-edge dummy. /// -/// Reserved scaffolding for the fully-joint flattened pass: when non- it would -/// mark this node as a hierarchy-crossing dummy standing in for a boundary port. The current -/// reconciliation-based recursive handling never assigns it, so it is for every -/// real node and every long-edge dummy, keeping default construction and every existing caller -/// byte-identical. +/// When non-, marks this node as a hierarchy-crossing dummy standing in for a +/// boundary port, recording the originating port and boundary face. The recursive layered pipeline +/// (LayeredLayoutPipeline.RunRecursive) is the producer, tagging crossing dummies after long-edge +/// splitting; the ordinary flat pipeline never assigns it, so it is for every +/// real node and every long-edge dummy on that path, keeping default construction and every existing +/// flat caller byte-identical. /// internal sealed record AugNode( double Width, diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs index 6136a10..e61dce5 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs @@ -53,6 +53,86 @@ public void Run(LayeredGraph graph) } } + /// + /// Runs the recursive (hierarchy-aware) layered pipeline over an assembled merge region, laying out + /// every nesting level and retaining each level's placed graph for the decomposition step. + /// + /// + /// + /// The same nine-stage sequence as is applied + /// per level, innermost first: , level-relative + /// , , hierarchy-aware + /// , , , + /// , , and . + /// Crossing minimization is the single stage coordinated across levels: each level's resolved + /// boundary order propagates between adjacent levels (see + /// ). Every other stage runs + /// independently per level, keeping each container's interior laid out inside its own box. + /// + /// + /// The pipeline's own is used for every level. The returned + /// exposes each level's placed graph and each + /// hierarchy-crossing dummy's placed coordinate and originating (BoundaryPort, Face). + /// + /// + /// The assembled merge region to lay out. + /// The per-level placed graphs and crossing-placement lookup for the decomposition step. + /// Thrown when is . + public RecursiveLayoutResult RunRecursive(AssembledMergeRegion region) + { + ArgumentNullException.ThrowIfNull(region); + + var levels = MergeRegionGraphAssembler.BuildAllLevelGraphs(region, Direction); + + // Stages 1-3 (+ crossing tag): normalize axes and break cycles per level, assign level-relative + // layers across the whole region, then split long edges and tag the hierarchy-crossing dummies. + foreach (var graph in levels.Values.Select(levelGraph => levelGraph.Graph)) + { + AxisTransform.NormalizeInputAxes(graph); + new CycleBreaker().Apply(graph); + } + + new LayerAssigner().AssignLayersRecursive(region.Root, levels); + + foreach (var levelGraph in levels.Values) + { + new LongEdgeSplitter().Apply(levelGraph.Graph); + TagCrossings(levelGraph); + } + + // Stage 4: hierarchy-aware crossing minimization with boundary-order propagation. + new CrossingMinimizer().MinimizeCrossingsRecursive(region.Root, levels); + + // Stages 5-9: place, distribute ports, route, join long edges, and transform axes, per level. + foreach (var graph in levels.Values.Select(levelGraph => levelGraph.Graph)) + { + new BrandesKopfPlacer().Apply(graph); + new PortDistributor().Apply(graph); + new LayeredCorridorRouter().Apply(graph); + new LongEdgeJoiner().Apply(graph); + new AxisTransform().Apply(graph); + } + + return new RecursiveLayoutResult(region, levels); + } + + /// + /// Tags each hierarchy-crossing dummy's augmented node with its originating boundary port and face, + /// wiring from scaffolding into a genuinely honored field. + /// + /// The level graph whose crossing dummies are tagged (after long-edge splitting). + private static void TagCrossings(LevelLayeredGraph levelGraph) + { + var aug = levelGraph.Graph.AugNodes; + foreach (var crossing in levelGraph.Crossings) + { + aug[crossing.NodeIndex] = aug[crossing.NodeIndex] with + { + Crossing = new HierarchyCrossing(crossing.Boundary.Port, crossing.Face), + }; + } + } + /// /// Fluent builder that assembles a from an ordered list /// of stages plus a direction and hierarchy-handling selection. @@ -117,13 +197,12 @@ public PipelineBuilder AddDefaultStages() /// hierarchy-crossing dummy participates in exactly the same layer-assignment, /// crossing-minimization, and placement stages as an ordinary node or long-edge dummy — ELK's /// own compound-graph design. The Recursive-specific behavior is not a distinct stage but the - /// way the caller seeds the graph with zero-size crossing dummies and reads their placed - /// positions back out (see BoundaryPortResolver.OrderCrossings, the ordering primitive - /// that drives this pipeline). This method exists so a Recursive pipeline is assembled through - /// its own explicit entry point, keeping the Flat path untouched. - /// Today the primitive is unit-tested and reserved for the fully-joint pass; production placement - /// is reconciliation-based (see ), so the seeded dummies - /// do not yet carry a descriptor into the augmented graph. + /// way drives this sequence per nesting level and + /// propagates each level's resolved boundary order between adjacent levels (see + /// MergeRegionGraphAssembler for the crossing-dummy seeding and + /// for the propagation). This method + /// exists so a Recursive pipeline is assembled through its own explicit entry point, keeping the + /// Flat path untouched. /// /// This builder, for chaining. public PipelineBuilder AddRecursiveStages() @@ -149,3 +228,53 @@ public LayeredLayoutPipeline Build() } } } + +/// +/// The result of : every nesting level's placed layered +/// graph plus the means to recover each hierarchy-crossing dummy's placed coordinate and originating +/// boundary, so the decomposition step can project the combined pass back into per-scope geometry. +/// +/// +/// +/// Per-level placed coordinates are read directly from a level's : +/// [i]/[i] is the placed +/// centre of augmented node i, where indices 0..N-1 align with the level's +/// in order, and each original edge's routed polyline is in +/// . +/// +/// +/// A crossing dummy's placed coordinate and its (BoundaryPort, Face) are recovered together +/// from , which pairs each level's with +/// the placed coordinate at its node index. +/// +/// +/// The assembled merge region that was laid out. +/// The lookup from each nesting level to its placed layered graph and crossing bookkeeping. +internal sealed record RecursiveLayoutResult( + AssembledMergeRegion Region, + IReadOnlyDictionary Levels) +{ + /// + /// Returns every hierarchy crossing placed at , each paired with its + /// originating boundary port, boundary face, and placed centre coordinate. + /// + /// The nesting level whose crossing placements are read. + /// The boundary port, face, and placed centre of each crossing at the level. + /// Thrown when is . + public IReadOnlyList<(BoundaryPort Boundary, HierarchyCrossingFace Face, Point2D Position)> CrossingPlacements( + MergeRegionLevel level) + { + ArgumentNullException.ThrowIfNull(level); + + var levelGraph = Levels[level]; + var graph = levelGraph.Graph; + var result = new List<(BoundaryPort, HierarchyCrossingFace, Point2D)>(levelGraph.Crossings.Count); + foreach (var crossing in levelGraph.Crossings) + { + var position = new Point2D(graph.AugX[crossing.NodeIndex], graph.AugY[crossing.NodeIndex]); + result.Add((crossing.Boundary, crossing.Face, position)); + } + + return result; + } +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs index a511ba8..1c3aacc 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LongEdgeSplitter.cs @@ -13,7 +13,7 @@ internal sealed class LongEdgeSplitter : ILayoutStage public void Apply(LayeredGraph graph) { ArgumentNullException.ThrowIfNull(graph); - var (augNodes, augEdges) = InsertLongEdgeDummies(graph.N, graph.Nodes, graph.NodeLayers, graph.Acyclic); + var (augNodes, augEdges) = InsertLongEdgeDummies(graph.N, graph.Nodes, graph.NodeLayers, graph.Acyclic, graph.AugNodes); graph.AugNodes = augNodes; graph.AugEdges = augEdges; } @@ -23,16 +23,28 @@ public void Apply(LayeredGraph graph) /// inserting one zero-size dummy node at each intermediate layer, following /// ELK's LongEdgeSplitter phase. /// + /// + /// A hierarchy-crossing dummy (a real node carrying a non- + /// , pre-seeded into by the recursive + /// pipeline) is a zero-size terminal hop across a container boundary, never an intermediate long-edge + /// relay: its tag is carried forward unchanged so it is never further + /// split. For the ordinary flat path is empty, so every rebuilt node + /// keeps the default tag and the output is byte-identical. + /// private static (List AugNodes, List AugEdges) InsertLongEdgeDummies( int n, IReadOnlyList nodes, int[] nodeLayers, - List acyclic) + List acyclic, + IReadOnlyList priorAug) { var augNodes = new List(n + acyclic.Count); for (var i = 0; i < n; i++) { - augNodes.Add(new AugNode(nodes[i].Width, nodes[i].Height, nodeLayers[i])); + // Carry forward any hierarchy-crossing tag a caller pre-seeded for this real node, so a + // crossing dummy stays a tagged terminal hop rather than being treated as an ordinary node. + var crossing = i < priorAug.Count ? priorAug[i].Crossing : null; + augNodes.Add(new AugNode(nodes[i].Width, nodes[i].Height, nodeLayers[i], Crossing: crossing)); } var augEdges = new List(acyclic.Count * 2); diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs index 981aa82..109788c 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs @@ -46,8 +46,17 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// (BoundaryPorts[i].Container is Children[i].Container). /// /// -/// The parent boundary port whose internal (delegation) edges cross into this scope, or -/// for the outermost (root) level. +/// The first parent boundary port whose internal (delegation) edges cross into this scope, or +/// for the outermost (root) level. Convenience accessor for the common +/// single-port case; carries every incoming boundary when the owning +/// container exposes more than one boundary port. +/// +/// +/// Every parent boundary port whose internal (delegation) edges cross into this scope — one per +/// boundary port owned by the container this level was descended into — or an empty list for the +/// outermost (root) level. A multi-port container contributes one whose +/// incoming boundaries are all of that container's ports, so each port becomes its own inward +/// hierarchy crossing. /// /// /// The effective bounding-box size lookup used when translating this level's nodes into @@ -62,6 +71,7 @@ internal sealed record MergeRegionLevel( IReadOnlyList<(LayoutGraphNode Container, int NodeIndex, MergeRegionLevel Child)> Children, IReadOnlyList BoundaryPorts, BoundaryPort? IncomingBoundary, + IReadOnlyList IncomingBoundaries, IReadOnlyDictionary EffectiveSize); /// @@ -77,6 +87,39 @@ internal sealed record AssembledMergeRegion( MergeRegionLevel Root, IReadOnlyList AllBoundaryPorts); +/// +/// Describes one hierarchy-crossing dummy node inside a level's assembled : +/// which augmented-node index stands in for the crossing, the boundary port it originates from, and +/// which logical face of the container boundary it represents. +/// +/// +/// The recursive pipeline uses this record for two purposes: to tag the corresponding +/// after long-edge splitting (so the crossing dummy is a genuinely +/// honored hierarchy crossing rather than an ordinary node), and — by pairing an +/// crossing in the parent level with the +/// crossing in the child level that shares the same +/// — to propagate a resolved boundary order between adjacent +/// nesting levels. +/// +/// The augmented-node index of the crossing dummy within its level graph. +/// The boundary port this crossing dummy stands in for. +/// Which logical face (external/internal) of the boundary crossing this dummy is. +internal readonly record struct LevelCrossing( + int NodeIndex, + BoundaryPort Boundary, + HierarchyCrossingFace Face); + +/// +/// One nesting level's assembled paired with the hierarchy-crossing dummies +/// seeded into it, so the recursive layered pipeline can both tag those dummies and recover each +/// crossing's placed coordinate and originating (BoundaryPort, Face) after placement. +/// +/// The per-level layered graph built by . +/// The hierarchy-crossing dummies seeded into , in creation order. +internal sealed record LevelLayeredGraph( + LayeredGraph Graph, + IReadOnlyList Crossings); + /// /// Assembles the combined, hierarchy-aware node/edge model for one merge region from the boundary ports /// discovered in a scope, so the recursive layered @@ -133,10 +176,49 @@ public static AssembledMergeRegion Assemble( // Accumulate every boundary port discovered at any depth for the decomposition step's lookup, // in top-down discovery order (this level's ports precede its descendants'). var allBoundaryPorts = new List(); - var root = BuildLevel(scope, boundaryPorts, incomingBoundary: null, effectiveSize, allBoundaryPorts); + var root = BuildLevel(scope, boundaryPorts, incomingBoundaries: [], effectiveSize, allBoundaryPorts); return new AssembledMergeRegion(root, allBoundaryPorts); } + /// + /// Builds one per nesting level of , keyed + /// by , so the recursive pipeline can lay out every level and later + /// recover each level's placed coordinates and hierarchy crossings. + /// + /// The assembled merge region whose every level is built. + /// The flow direction each level's graph is laid out along. + /// A lookup from each level to its assembled layered graph and crossing bookkeeping. + /// Thrown when is . + public static IReadOnlyDictionary BuildAllLevelGraphs( + AssembledMergeRegion region, + LayoutDirection direction) + { + ArgumentNullException.ThrowIfNull(region); + + var result = new Dictionary(); + BuildAllLevelGraphs(region.Root, direction, result); + return result; + } + + /// + /// Recursively populates with the assembled layered graph of + /// and every descendant level. + /// + /// The level to build a graph for. + /// The flow direction each level's graph is laid out along. + /// The accumulating level-to-graph lookup. + private static void BuildAllLevelGraphs( + MergeRegionLevel level, + LayoutDirection direction, + Dictionary sink) + { + sink[level] = BuildLevelGraph(level, direction); + foreach (var (_, _, child) in level.Children) + { + BuildAllLevelGraphs(child, direction, sink); + } + } + /// /// Builds the per-level for one : real /// interior nodes translated to s, ordinary interior edges to @@ -154,9 +236,9 @@ public static AssembledMergeRegion Assemble( /// /// The nesting level to build a layered graph for. /// The flow direction the level is laid out along; defaults to . - /// The per-level layered graph, ready to feed the recursive pipeline. + /// The per-level layered graph and its hierarchy-crossing bookkeeping, ready to feed the recursive pipeline. /// Thrown when is . - internal static LayeredGraph BuildLevelGraph(MergeRegionLevel level, LayoutDirection direction = LayoutDirection.Right) + internal static LevelLayeredGraph BuildLevelGraph(MergeRegionLevel level, LayoutDirection direction = LayoutDirection.Right) { ArgumentNullException.ThrowIfNull(level); @@ -190,21 +272,23 @@ internal static LayeredGraph BuildLevelGraph(MergeRegionLevel level, LayoutDirec } } - // The incoming crossing: the parent boundary port delegates inward, so a single zero-size dummy - // (its internal face) feeds each interior delegation target. - if (level.IncomingBoundary is { } incoming) + var crossings = new List(); + + // The incoming crossings: each parent boundary port delegating inward becomes one zero-size + // dummy (its internal face) feeding the interior delegation targets it reaches. + foreach (var incoming in level.IncomingBoundaries) { - AddIncomingCrossing(level, incoming, nodes, edges); + AddIncomingCrossing(level, incoming, nodes, edges, crossings); } // The outgoing crossings: each boundary-port container's external approach edges feed a zero-size // dummy (its external face) that then leads into the container's own box. foreach (var boundary in level.BoundaryPorts) { - AddOutgoingCrossing(level, boundary, nodes, edges); + AddOutgoingCrossing(level, boundary, nodes, edges, crossings); } - return new LayeredGraph(nodes, edges, direction); + return new LevelLayeredGraph(new LayeredGraph(nodes, edges, direction), crossings); } /// @@ -214,14 +298,14 @@ internal static LayeredGraph BuildLevelGraph(MergeRegionLevel level, LayoutDirec /// /// The container scope this level is drawn from. /// The boundary ports discovered directly in . - /// The parent boundary port delegating into this scope, or at the root. + /// The parent boundary ports delegating into this scope, or empty at the root. /// The effective size lookup threaded to every level. /// The accumulating flattened boundary-port list. /// The assembled level for . private static MergeRegionLevel BuildLevel( LayoutGraph scope, IReadOnlyList boundaryPorts, - BoundaryPort? incomingBoundary, + IReadOnlyList incomingBoundaries, IReadOnlyDictionary effectiveSize, List allBoundaryPorts) { @@ -233,16 +317,16 @@ private static MergeRegionLevel BuildLevel( nodeIndex[nodes[i]] = i; } - // Boundary-crossing edges reference either a boundary port discovered here or the parent - // boundary port delegating in; they are represented as hierarchy crossings, so they are removed - // from the level's ordinary edge set. + // Boundary-crossing edges reference either a boundary port discovered here or a parent boundary + // port delegating in; they are represented as hierarchy crossings, so they are removed from the + // level's ordinary edge set. var boundaryPortSet = new HashSet(); foreach (var boundary in boundaryPorts) { boundaryPortSet.Add(boundary.Port); } - if (incomingBoundary is { } incoming) + foreach (var incoming in incomingBoundaries) { boundaryPortSet.Add(incoming.Port); } @@ -254,15 +338,32 @@ private static MergeRegionLevel BuildLevel( // This level's own boundary ports precede its descendants' in the flattened lookup. allBoundaryPorts.AddRange(boundaryPorts); + // Group boundary ports by their owning container so a multi-port container contributes exactly + // one nested child level whose incoming boundaries are all of that container's ports, rather than + // one duplicated child level per port. + var containerOrder = new List(); + var portsByContainer = new Dictionary>(); + foreach (var boundary in boundaryPorts) + { + if (!portsByContainer.TryGetValue(boundary.Container, out var ports)) + { + ports = []; + portsByContainer[boundary.Container] = ports; + containerOrder.Add(boundary.Container); + } + + ports.Add(boundary); + } + // Recurse into every boundary-port container's full child scope, rediscovering that scope's own // boundary ports so a delegation chain of any depth is assembled level by level. var children = new List<(LayoutGraphNode Container, int NodeIndex, MergeRegionLevel Child)>(); - foreach (var boundary in boundaryPorts) + foreach (var container in containerOrder) { - var container = boundary.Container; + var containerPorts = portsByContainer[container]; var childScope = container.Children; var childBoundaries = HierarchyMergeRegionBuilder.Collect(childScope); - var childLevel = BuildLevel(childScope, childBoundaries, boundary, effectiveSize, allBoundaryPorts); + var childLevel = BuildLevel(childScope, childBoundaries, containerPorts, effectiveSize, allBoundaryPorts); children.Add((container, nodeIndex[container], childLevel)); } @@ -273,7 +374,8 @@ private static MergeRegionLevel BuildLevel( nodeIndex, children, boundaryPorts, - incomingBoundary, + incomingBoundaries.Count > 0 ? incomingBoundaries[0] : null, + incomingBoundaries, effectiveSize); } @@ -285,11 +387,13 @@ private static MergeRegionLevel BuildLevel( /// The parent boundary port delegating into the level. /// The working layer-node list, appended to in place. /// The working layer-edge list, appended to in place. + /// The working crossing-metadata list, appended to in place. private static void AddIncomingCrossing( MergeRegionLevel level, BoundaryPort incoming, List nodes, - List edges) + List edges, + List crossings) { var dummyIndex = nodes.Count; var wired = false; @@ -305,6 +409,7 @@ private static void AddIncomingCrossing( if (!wired) { nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); + crossings.Add(new LevelCrossing(dummyIndex, incoming, HierarchyCrossingFace.Internal)); wired = true; } @@ -320,11 +425,13 @@ private static void AddIncomingCrossing( /// The boundary port whose external approach is being wired. /// The working layer-node list, appended to in place. /// The working layer-edge list, appended to in place. + /// The working crossing-metadata list, appended to in place. private static void AddOutgoingCrossing( MergeRegionLevel level, BoundaryPort boundary, List nodes, - List edges) + List edges, + List crossings) { if (!level.NodeIndex.TryGetValue(boundary.Container, out var containerIndex)) { @@ -348,6 +455,7 @@ private static void AddOutgoingCrossing( { nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); edges.Add(new LayerEdge(dummyIndex, containerIndex)); + crossings.Add(new LevelCrossing(dummyIndex, boundary, HierarchyCrossingFace.External)); wired = true; } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerRecursionTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerRecursionTests.cs new file mode 100644 index 0000000..dbd3029 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/CrossingMinimizerRecursionTests.cs @@ -0,0 +1,214 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Tests for the hierarchy-aware entry +/// point: genuine ELK-style recursive-descent crossing minimization over an assembled merge region, +/// where a child level's resolved boundary order propagates up into its parent's sweep, and the +/// parent's resolved boundary order (decided under outer-scope crossing pressure) propagates back +/// down so an ordinary interior node reorders. These tests operate on small hand-built two-level +/// hierarchies against the isolation seam, never through production wiring. +/// +public sealed class CrossingMinimizerRecursionTests +{ + /// + /// A two-level hierarchy in which the parent's own external crossing pressure is neutral (both + /// boundary ports of the container are fed by the same single external node) resolves its + /// boundary-crossing order by adopting the child level's resolved incoming order — proving the + /// child's order propagates upward and constrains the parent's barycenter sweep rather than the + /// parent falling back to boundary-discovery (index) order. + /// + [Fact] + public void CrossingMinimizer_MinimizeCrossingsRecursive_TwoLevelHierarchy_ChildOrderPropagatesToParent() + { + // Arrange: parent scope { X, B(ports p1, p2) }; X fans out to both p1 and p2 (neutral external + // pressure). B.Children { Ta, Tb, Pa, Pb } with delegations p1->Tb and p2->Ta, and ordinary + // interior pins Pa->Ta (Pa above Pb by insertion) and Pb->Tb that fix Ta above Tb. The child's + // own barycenter sweep therefore resolves its incoming crossing order to [p2, p1] — the reverse + // of the boundary-discovery order [p1, p2]. + var scope = new LayoutGraph(); + var x = scope.AddNode("X", 80, 40); + var b = scope.AddNode("B", 10, 10); + var p1 = b.Ports.AddPort("p1"); + var p2 = b.Ports.AddPort("p2"); + var ta = b.Children.AddNode("Ta", 80, 40); + var tb = b.Children.AddNode("Tb", 80, 40); + var pa = b.Children.AddNode("Pa", 80, 40); + var pb = b.Children.AddNode("Pb", 80, 40); + scope.AddEdge("x-p1", x, p1); + scope.AddEdge("x-p2", x, p2); + b.Children.AddEdge("p1-tb", p1, tb); + b.Children.AddEdge("p2-ta", p2, ta); + b.Children.AddEdge("pa-ta", pa, ta); + b.Children.AddEdge("pb-tb", pb, tb); + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + var region = MergeRegionGraphAssembler.Assemble(scope, boundaries, EmptySize()); + var childLevel = region.Root.Children[0].Child; + var levels = MergeRegionGraphAssembler.BuildAllLevelGraphs(region, LayoutDirection.Right); + PrepareLevels(region.Root, levels); + + // Act + new CrossingMinimizer().MinimizeCrossingsRecursive(region.Root, levels); + + // Assert: the child resolved its incoming order to [p2, p1], and the parent adopted exactly that + // order for its outgoing crossings — the child order propagated upward. + var childOrder = CrossingOrder(levels[childLevel], HierarchyCrossingFace.Internal); + var parentOrder = CrossingOrder(levels[region.Root], HierarchyCrossingFace.External); + Assert.Equal(new[] { p2, p1 }, childOrder); + Assert.Equal(new[] { p2, p1 }, parentOrder); + Assert.Equal(childOrder, parentOrder); + + // Assert: the resolved parent order genuinely differs from the boundary-discovery (index) order, + // so the assertion is not tautological — the propagation actually changed the outcome. + Assert.NotEqual(new[] { p1, p2 }, parentOrder); + } + + /// + /// A two-level hierarchy in which the parent's external crossing pressure is decisive (two + /// distinct external nodes feed the container's two boundary ports in an order opposite to the + /// child's own preference) forces the parent's boundary order, and that order propagates back + /// down into the child. The result reorders an ordinary (non-boundary) interior node: + /// its position under outer-scope pressure differs from the position the child's own isolated + /// crossing minimization would give it. This is a genuine demonstration of cross-boundary + /// optimization, asserted against the child's naive (no outer influence) baseline. + /// + [Fact] + public void CrossingMinimizer_InteriorNodeReordering_OrdinaryNodeParticipatesInRealCrossingMinimization() + { + // Arrange: parent scope { N, M, B(ports p1, p2) }; N->p2 and M->p1 give the parent a decisive, + // distinct external order [p2, p1] (opposite to the child's own preference [p1, p2]). B.Children + // { T1, T2, Ord } with delegations p1->T1, p2->T2, and p2->Ord, so Ord is an ordinary interior + // node reached only by the p2 crossing. In isolation the child orders Ord LAST; under the + // parent's decisive [p2, p1] order propagated down, Ord moves to the MIDDLE. + var scope = new LayoutGraph(); + var n = scope.AddNode("N", 80, 40); + var m = scope.AddNode("M", 80, 40); + var b = scope.AddNode("B", 10, 10); + var p1 = b.Ports.AddPort("p1"); + var p2 = b.Ports.AddPort("p2"); + var t1 = b.Children.AddNode("T1", 80, 40); + var t2 = b.Children.AddNode("T2", 80, 40); + var ord = b.Children.AddNode("Ord", 80, 40); + scope.AddEdge("n-p2", n, p2); + scope.AddEdge("m-p1", m, p1); + b.Children.AddEdge("p1-t1", p1, t1); + b.Children.AddEdge("p2-t2", p2, t2); + b.Children.AddEdge("p2-ord", p2, ord); + var boundaries = HierarchyMergeRegionBuilder.Collect(scope); + var region = MergeRegionGraphAssembler.Assemble(scope, boundaries, EmptySize()); + var childLevel = region.Root.Children[0].Child; + var ordIndex = childLevel.NodeIndex[ord]; + + // Act 1 (baseline): minimize the child level in ISOLATION with the flat entry point, so it sees + // no outer-scope pressure. Ord settles into its child-alone position. + var baselineLevels = MergeRegionGraphAssembler.BuildAllLevelGraphs(region, LayoutDirection.Right); + PrepareLevels(region.Root, baselineLevels); + new CrossingMinimizer().Apply(baselineLevels[childLevel].Graph); + var naiveOrdPosition = PositionInLayer(baselineLevels[childLevel], ordIndex); + + // Act 2 (recursive): minimize the whole hierarchy, so the parent's decisive external order + // propagates down and re-minimizes the child under outer-scope pressure. + var recursiveLevels = MergeRegionGraphAssembler.BuildAllLevelGraphs(region, LayoutDirection.Right); + PrepareLevels(region.Root, recursiveLevels); + new CrossingMinimizer().MinimizeCrossingsRecursive(region.Root, recursiveLevels); + var recursiveOrdPosition = PositionInLayer(recursiveLevels[childLevel], ordIndex); + + // Assert: in isolation the ordinary node Ord is ordered last (position 2); under outer-scope + // pressure it moves to the middle (position 1). Its order genuinely changed because of the outer + // scope, not merely its edge routing. + Assert.Equal(2, naiveOrdPosition); + Assert.Equal(1, recursiveOrdPosition); + Assert.NotEqual(naiveOrdPosition, recursiveOrdPosition); + } + + /// + /// Runs the per-level preparation stages that must precede crossing minimization — cycle + /// breaking, level-relative layer assignment, and long-edge splitting — mirroring the sequence + /// applies before its crossing-minimization + /// stage, so a test can drive in + /// isolation. + /// + /// The root nesting level of the assembled region. + /// The per-level layered graphs to prepare in place. + private static void PrepareLevels( + MergeRegionLevel root, + IReadOnlyDictionary levels) + { + foreach (var levelGraph in levels.Values) + { + new CycleBreaker().Apply(levelGraph.Graph); + } + + new LayerAssigner().AssignLayersRecursive(root, levels); + + foreach (var levelGraph in levels.Values) + { + new LongEdgeSplitter().Apply(levelGraph.Graph); + } + } + + /// + /// Returns the boundary ports of the crossings on in their resolved + /// cross-axis order — their position within their layer group in . + /// + /// The already-minimized level graph. + /// The crossing face to read. + /// The boundary ports in resolved cross-axis order. + private static List CrossingOrder(LevelLayeredGraph levelGraph, HierarchyCrossingFace face) + { + var positions = Positions(levelGraph.Graph.Groups); + return levelGraph.Crossings + .Where(crossing => crossing.Face == face) + .OrderBy(crossing => positions[crossing.NodeIndex]) + .Select(crossing => crossing.Boundary.Port) + .ToList(); + } + + /// Returns the position of augmented node within its layer group. + /// The minimized level graph. + /// The augmented-node index to locate. + /// The zero-based position within the node's layer, or -1 when absent. + private static int PositionInLayer(LevelLayeredGraph levelGraph, int nodeIndex) + { + foreach (var layer in levelGraph.Graph.Groups) + { + var position = layer.IndexOf(nodeIndex); + if (position >= 0) + { + return position; + } + } + + return -1; + } + + /// Returns a lookup from augmented-node index to its position within its layer group. + /// The per-layer ordered index lists. + /// A lookup from node index to cross-axis position. + private static Dictionary Positions(List> groups) + { + var positions = new Dictionary(); + foreach (var layer in groups) + { + for (var i = 0; i < layer.Count; i++) + { + positions[layer[i]] = i; + } + } + + return positions; + } + + /// Creates an empty effective-size lookup, so every node uses its own declared dimensions. + /// An empty node-to-size lookup. + private static IReadOnlyDictionary EmptySize() + { + return new Dictionary(); + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs index 6a99c4c..80765d1 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/LongEdgeSplitterTests.cs @@ -2,6 +2,7 @@ // Copyright (c) DemaConsulting. All rights reserved. // +using DemaConsulting.Rendering; using DemaConsulting.Rendering.Layout.Engine; using DemaConsulting.Rendering.Layout.Engine.Layered; @@ -56,4 +57,39 @@ public void LongEdgeSplitter_Apply_LongEdge_InsertsDummyNodesPerIntermediateLaye Assert.Equal(graph.N + 2, graph.AugNodes.Count); Assert.Equal(2, graph.AugNodes.Count(a => a.IsDummy)); } + + /// + /// A pre-seeded hierarchy-crossing dummy (a real node carrying a non-null + /// tag) is a zero-size terminal hop across a container boundary, + /// so long-edge splitting carries its tag forward unchanged and never rebuilds it into an + /// ordinary node — the consumer guard for the recursive pipeline's crossing dummies. + /// + [Fact] + public void LongEdgeSplitter_Apply_CrossingTaggedNode_PreservesTagAndIsNotSplit() + { + // Arrange: a two-node graph (feeder -> crossing dummy) whose target node is pre-seeded as a + // hierarchy-crossing dummy, exactly as the recursive pipeline tags a boundary-crossing node. + var port = new LayoutGraph().AddNode("Container", 10, 10).Ports.AddPort("p"); + var nodes = new List { new(60, 40), new(0, 0, RealWidth: 0, RealHeight: 0) }; + var edges = new List { new(0, 1) }; + var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + new CycleBreaker().Apply(graph); + new LayerAssigner().Apply(graph); + graph.AugNodes = + [ + new AugNode(nodes[0].Width, nodes[0].Height, graph.NodeLayers[0]), + new AugNode(0, 0, graph.NodeLayers[1], Crossing: new HierarchyCrossing(port, HierarchyCrossingFace.Internal)), + ]; + + // Act: split long edges with the crossing tag already present. + new LongEdgeSplitter().Apply(graph); + + // Assert: the crossing tag survived the augmented-node rebuild unchanged, and the tagged node was + // not turned into a long-edge dummy nor duplicated (no split of the terminal crossing hop). + Assert.NotNull(graph.AugNodes[1].Crossing); + Assert.Equal(HierarchyCrossingFace.Internal, graph.AugNodes[1].Crossing!.Value.Face); + Assert.Same(port, graph.AugNodes[1].Crossing!.Value.Port); + Assert.False(graph.AugNodes[1].IsDummy); + Assert.DoesNotContain(graph.AugNodes, a => a.IsDummy); + } } From 89df4a0324c6d1f726e9ed1f1038bee57b9698f8 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 10:31:30 -0400 Subject: [PATCH 13/23] feat(layout): single-pass hierarchical boundary-port routing via decomposition (fixes fan-in diagonal defect) --- .../Engine/Layered/BoundaryPortResolver.cs | 485 +--------------- .../Engine/Layered/HierarchyHandling.cs | 36 +- .../Engine/Layered/MergeRegionDecomposer.cs | 542 ++++++++++++++++++ .../Layered/MergeRegionGraphAssembler.cs | 72 ++- .../HierarchicalLayoutAlgorithm.cs | 162 +++++- .../Layered/BoundaryPortResolverTests.cs | 273 +-------- .../Layered/MergeRegionDecomposerTests.cs | 161 ++++++ .../HierarchicalLayoutAlgorithmTests.cs | 304 +++++++++- 8 files changed, 1255 insertions(+), 780 deletions(-) create mode 100644 src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionDecomposer.cs create mode 100644 test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs index 5dde722..1df6cf0 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BoundaryPortResolver.cs @@ -2,497 +2,34 @@ // Copyright (c) DemaConsulting. All rights reserved. // -using DemaConsulting.Rendering.Layout.Engine; - namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// -/// The placed connectors and enriched port anchors a pass produces -/// for one container scope. -/// -/// -/// The scope's final port list: the leaf pass's ports with each boundary port's anchor enriched to -/// carry both its external and internal labels and any external fan-out anchors consolidated to a -/// single shared anchor, plus a synthesized anchor for every delegation port the leaf pass could not -/// anchor on its own. -/// -/// -/// The scope's final connector list: the leaf pass's lines (with external fan-out lines re-terminated -/// onto the shared anchor) plus one new orthogonal connector per internal delegation edge, each -/// reaching the boundary port's single shared anchor. -/// -internal sealed record BoundaryResolution( - IReadOnlyList Ports, - IReadOnlyList Lines); - -/// -/// Resolves boundary (delegation) ports for one container scope into a single shared anchor per port, -/// carrying both the external and internal labels, and wires every external and internal edge that -/// converges on the port to that one anchor — the placement half of the ELK-style recursive hierarchy -/// handling. +/// Maps a scope's flow direction to the container boundary face a delegation (boundary) port sits on, +/// the one shared reference the recursive hierarchy handling uses to place a boundary anchor and to +/// join its external approach and internal delegation across the container boundary. /// /// -/// -/// A boundary port must resolve to exactly one physical anchor on its container's boundary so the -/// external approach (routed by the parent scope's leaf pass) and the internal delegation (routed -/// here, into the container's own placed children) meet consistently. When the leaf pass already -/// anchored the port — because its external edges are ordinary sibling edges it could route — that -/// leaf anchor is authoritative and is simply enriched with the internal label; any additional -/// external fan-out anchors the leaf spread across the face are consolidated back onto it. When the -/// leaf pass could not anchor the port — because the port is a further link in a delegation chain -/// whose external edge is the parent container's own boundary port — a single anchor is synthesized -/// on the boundary face facing the flow, its position derived from a combined layered pass over the -/// hierarchy-crossing dummies and interior targets so multiple ports on one face never collide. -/// -/// -/// This type never runs for a scope with no boundary ports (the caller gates on a non-empty -/// result), so every -/// boundary-port-free graph keeps its existing, byte-identical output. -/// +/// The production placement of boundary ports is the single combined pass: +/// assembles the hierarchy-aware graph, +/// lays every nesting level out in one coordinated +/// placement, and projects the placed result back into per-scope +/// geometry. This type retains only the direction-to-face mapping that both the decomposer's anchor +/// placement and its orthogonal boundary joins share. /// internal static class BoundaryPortResolver { - /// Tolerance, in logical pixels, for deciding an anchor point lies on a box face. - private const double BoundaryTolerance = 0.1; - - /// Clearance, in logical pixels, bounding a synthesized anchor's port label width. - private const double PortLabelClearance = 4.0; - - /// - /// Resolves every boundary port of against the scope's already-composed, - /// already-placed geometry, producing the final port and connector lists. - /// - /// The container scope whose boundary ports are resolved. - /// The scope's resolved flow direction, used to pick the boundary face. - /// The boundary ports discovered by . - /// The scope's composed top-level boxes, aligned with its nodes by index. - /// Map from each direct-member node to its index in . - /// The leaf pass's emitted ports for this scope. - /// The leaf pass's emitted connector lines for this scope. - /// The enriched ports and the final connector lines, including internal delegation connectors. - /// Thrown when any reference argument is . - public static BoundaryResolution Resolve( - LayoutGraph scope, - LayoutDirection direction, - IReadOnlyList boundaryPorts, - IReadOnlyList composed, - IReadOnlyDictionary indexOf, - IReadOnlyList placedPorts, - IReadOnlyList placedLines) - { - ArgumentNullException.ThrowIfNull(scope); - ArgumentNullException.ThrowIfNull(boundaryPorts); - ArgumentNullException.ThrowIfNull(composed); - ArgumentNullException.ThrowIfNull(indexOf); - ArgumentNullException.ThrowIfNull(placedPorts); - ArgumentNullException.ThrowIfNull(placedLines); - - var ports = new List(placedPorts); - var lines = new List(placedLines); - - // Group same-face synthesized ports per container so multiple delegation ports on one face can - // be spread deterministically by the combined layered ordering rather than colliding. - var boundaryFace = FaceForDirection(direction); - - foreach (var boundary in boundaryPorts) - { - var containerBox = composed[indexOf[boundary.Container]]; - ResolveOne(boundary, containerBox, boundaryFace, direction, ports, lines); - } - - return new BoundaryResolution(ports, lines); - } - - /// - /// Orders a set of hierarchy-crossing dummies along the boundary face by running the recursive - /// layered pipeline over the crossings and their interior targets, returning the crossings' indices - /// sorted by their placed cross-axis position. - /// - /// - /// This is the combined-pass ordering primitive for the recursive hierarchy handling, exposed for - /// direct unit testing: it builds a small flattened graph — one zero-size crossing dummy per - /// crossing feeding the interior targets — and runs the recursive-mode pipeline so the relative - /// order the pipeline assigns the crossings is the order they should occupy along the shared - /// boundary face. It is reserved for the fully-joint pass and for ordering several crossings that - /// share one face; the current production reconciliation aligns each boundary anchor by - /// mean-of-targets (see ) rather than consuming this - /// ordering, so today this method is covered by unit tests but does not yet govern placement. - /// - /// The hierarchy crossings to order, one zero-size dummy each. - /// The interior target node sizes each crossing delegates to, in order. - /// The flow direction the combined pass lays the region out along. - /// The indices into sorted by placed cross-axis position. - /// Thrown when or is . - public static IReadOnlyList OrderCrossings( - IReadOnlyList crossings, - IReadOnlyList<(double Width, double Height)> targetSizes, - LayoutDirection direction) - { - ArgumentNullException.ThrowIfNull(crossings); - ArgumentNullException.ThrowIfNull(targetSizes); - - if (crossings.Count == 0) - { - return []; - } - - // Build a small flattened graph: one zero-size hierarchy-crossing dummy per crossing (layer 0), - // followed by the interior targets, with each crossing delegating to every target so the - // pipeline lays the crossings out on the near side and the targets one layer in. - var nodes = new List(crossings.Count + targetSizes.Count); - for (var i = 0; i < crossings.Count; i++) - { - nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); - } - - foreach (var (width, height) in targetSizes) - { - nodes.Add(new LayerNode(width, height, RealWidth: width, RealHeight: height)); - } - - var edges = new List(); - for (var c = 0; c < crossings.Count; c++) - { - for (var t = 0; t < targetSizes.Count; t++) - { - edges.Add(new LayerEdge(c, crossings.Count + t)); - } - } - - // Run the recursive-mode pipeline: the crossings are seeded as hierarchy-crossing dummies so - // the pass treats them as the boundary they stand in for. When there are no interior targets, - // fall back to input order (nothing to order against). - if (targetSizes.Count == 0) - { - return Enumerable.Range(0, crossings.Count).ToList(); - } - - var graph = new LayeredGraph(nodes, edges, direction); - var pipeline = LayeredLayoutPipeline.Builder() - .Direction(direction) - .Hierarchy(HierarchyHandling.Recursive) - .AddRecursiveStages() - .Build(); - pipeline.Run(graph); - - // The crossings occupy augmented-node indices 0..crossings.Count-1. Sort them by their placed - // cross-axis coordinate (Y for horizontal flow, X for vertical flow) to get their face order. - var transposed = direction is LayoutDirection.Down or LayoutDirection.Up; - var order = Enumerable.Range(0, crossings.Count).ToList(); - order.Sort((a, b) => - { - var ca = transposed ? graph.AugX[a] : graph.AugY[a]; - var cb = transposed ? graph.AugX[b] : graph.AugY[b]; - return ca.CompareTo(cb); - }); - - return order; - } - - /// - /// Resolves a single boundary port: establishes its one shared anchor, enriches or synthesizes the - /// port carrying both labels, consolidates external fan-out onto the anchor, and adds one internal - /// connector per delegation edge. - /// - /// The boundary port being resolved. - /// The composed box of the boundary port's owning container. - /// The boundary face a synthesized anchor is placed on. - /// The scope's flow direction. - /// The working port list, mutated in place. - /// The working connector list, mutated in place. - private static void ResolveOne( - BoundaryPort boundary, - LayoutBox containerBox, - PortSide boundaryFace, - LayoutDirection direction, - List ports, - List lines) - { - // Resolve every internal delegation edge's interior target once, so both the synthesized - // anchor position and the internal connectors can use the same geometry. - var targets = ResolveInteriorTargets(boundary, containerBox); - - // Establish the single shared anchor. Prefer the leaf pass's own anchor (authoritative for a - // port whose external edges are ordinary sibling edges); otherwise synthesize one. - var leafAnchors = FindLeafAnchors(boundary.Port, containerBox, ports); - Point2D anchorPoint; - PortSide side; - double maxLabelWidth; - if (leafAnchors.Count > 0) - { - var primary = leafAnchors[0]; - anchorPoint = new Point2D(primary.CentreX, primary.CentreY); - side = primary.Side; - maxLabelWidth = primary.MaxLabelWidth; - - // Consolidate external fan-out: re-terminate every other external line on the primary - // anchor and drop the duplicate leaf ports, so all external edges share the one anchor. - for (var i = 1; i < leafAnchors.Count; i++) - { - RetargetLinesEndingAt(lines, new Point2D(leafAnchors[i].CentreX, leafAnchors[i].CentreY), anchorPoint); - ports.Remove(leafAnchors[i]); - } - - ports.Remove(primary); - } - else - { - side = boundaryFace; - anchorPoint = SynthesizeAnchor(containerBox, side, targets, direction); - maxLabelWidth = Math.Max(0.0, (containerBox.Width / 2.0) - PortLabelClearance); - } - - // Emit the single enriched anchor carrying both labels. SourcePort tags this anchor with its - // originating graph port so a parent scope's own ResolveInteriorTargets nested-port branch can - // later find it by reference identity, not by label text (see FindLeafAnchors). - ports.Add(new LayoutPort( - anchorPoint.X, - anchorPoint.Y, - side, - boundary.Port.ExternalLabel, - boundary.Port.InternalLabel, - maxLabelWidth, - SourcePort: boundary.Port)); - - // Wire each internal delegation edge to the shared anchor with an orthogonal connector. - foreach (var target in targets) - { - lines.Add(new LayoutLine( - InteriorConnector(anchorPoint, side, target), - EndMarkerStyle.None, - EndMarkerStyle.None, - LineStyle.Solid, - null)); - } - } - - /// - /// Resolves each internal delegation edge of a boundary port to the interior geometry it terminates - /// at: a plain child node's composed box, or a nested boundary port's already-placed anchor. - /// - /// The boundary port whose internal edges are resolved. - /// The composed box whose children hold the interior targets. - /// One target rectangle per resolvable internal edge (a nested port anchor is a zero-size rect). - private static List ResolveInteriorTargets(BoundaryPort boundary, LayoutBox containerBox) - { - var childNodes = boundary.Container.Children.Nodes; - var childBoxes = containerBox.Children.OfType().ToList(); - var childPorts = containerBox.Children.OfType().ToList(); - - var targets = new List(); - foreach (var edge in boundary.InternalEdges) - { - var other = ReferenceEquals(edge.Source, boundary.Port) ? edge.Target : edge.Source; - switch (other) - { - case LayoutGraphNode node: - var index = IndexOfNode(childNodes, node); - if (index >= 0 && index < childBoxes.Count) - { - var box = childBoxes[index]; - targets.Add(new Rect(box.X, box.Y, box.Width, box.Height)); - } - - break; - - case LayoutGraphPort nestedPort: - // A delegation chain link: the interior target is another container's boundary port, - // already anchored inside this container by that container's own resolution pass. - // Matched by reference identity (SourcePort), not by ExternalLabel, since that field - // is optional and frequently null — two distinct nested ports sharing (or both - // omitting) a label must never be conflated. - var anchor = childPorts.FirstOrDefault(cp => ReferenceEquals(cp.SourcePort, nestedPort)); - if (anchor != null) - { - targets.Add(new Rect(anchor.CentreX, anchor.CentreY, 0.0, 0.0)); - } - - break; - - default: - break; - } - } - - return targets; - } - - /// - /// Finds the leaf pass's emitted anchors for a boundary port: the ports lying on the container box's - /// boundary whose is reference-identical to the boundary port, in - /// the order the leaf emitted them. Matching is by reference identity, not by - /// , since that field is optional and frequently null — two - /// independent boundary ports on the same container that share (or both omit) an external label must - /// still resolve to their own true leaf anchor, never each other's. - /// - /// The boundary port whose leaf anchors are sought. - /// The container box the anchors must lie on. - /// The working port list. - /// The matching leaf anchors; empty when the leaf pass anchored nothing for this port. - private static List FindLeafAnchors( - LayoutGraphPort port, - LayoutBox containerBox, - List ports) - { - return ports - .Where(candidate => - ReferenceEquals(candidate.SourcePort, port) && - candidate.InternalLabel is null && - OnBoxBoundary(candidate.CentreX, candidate.CentreY, containerBox)) - .ToList(); - } - - /// - /// Re-terminates every connector line whose final waypoint coincides with - /// so it ends at instead, collapsing external fan-out onto one shared anchor. - /// - /// The working connector list, mutated in place. - /// The anchor point being consolidated away. - /// The shared anchor point every line is re-terminated onto. - private static void RetargetLinesEndingAt(List lines, Point2D from, Point2D to) - { - for (var i = 0; i < lines.Count; i++) - { - var waypoints = lines[i].Waypoints; - if (waypoints.Count == 0) - { - continue; - } - - if (SamePoint(waypoints[^1], from)) - { - var updated = new List(waypoints) { [^1] = to }; - lines[i] = lines[i] with { Waypoints = updated }; - } - else if (SamePoint(waypoints[0], from)) - { - var updated = new List(waypoints) { [0] = to }; - lines[i] = lines[i] with { Waypoints = updated }; - } - } - } - - /// - /// Synthesizes a single anchor point on the boundary face for a delegation port the leaf pass could - /// not anchor, aligning it with the interior targets it delegates to so the connectors stay short. - /// - /// The container box the anchor lies on. - /// The boundary face the anchor is placed on. - /// The interior targets the port delegates to. - /// The scope's flow direction (reserved for combined-pass ordering). - /// The synthesized anchor point on the requested face. - private static Point2D SynthesizeAnchor( - LayoutBox containerBox, - PortSide side, - IReadOnlyList targets, - LayoutDirection direction) - { - // Exercise the combined layered ordering primitive over this face's single crossing so the - // reserved joint-pass path stays live and covered. Its result does not govern placement today: - // with one crossing there is nothing to reorder, and the anchor's along-face position is set by - // the mean-of-targets alignment below. Feeding OrderCrossings back for multi-crossing faces is - // the reserved refinement (see HierarchyHandling.Recursive). - var crossings = new[] { new HierarchyCrossing(new LayoutGraphPort("crossing"), HierarchyCrossingFace.Internal) }; - var targetSizes = targets.Select(t => (t.Width, t.Height)).ToList(); - _ = OrderCrossings(crossings, targetSizes, direction); - - // Align the anchor across the face with the mean centre of its interior targets, clamped inside - // the face so the anchor always lands on the drawn boundary. - double alongCentre; - if (side is PortSide.Left or PortSide.Right) - { - alongCentre = targets.Count > 0 - ? targets.Average(t => t.Y + (t.Height / 2.0)) - : containerBox.Y + (containerBox.Height / 2.0); - var y = Math.Clamp(alongCentre, containerBox.Y, containerBox.Y + containerBox.Height); - var x = side == PortSide.Left ? containerBox.X : containerBox.X + containerBox.Width; - return new Point2D(x, y); - } - - alongCentre = targets.Count > 0 - ? targets.Average(t => t.X + (t.Width / 2.0)) - : containerBox.X + (containerBox.Width / 2.0); - var clampedX = Math.Clamp(alongCentre, containerBox.X, containerBox.X + containerBox.Width); - var faceY = side == PortSide.Top ? containerBox.Y : containerBox.Y + containerBox.Height; - return new Point2D(clampedX, faceY); - } - - /// - /// Builds an orthogonal connector from a boundary anchor into the container interior, terminating on - /// the interior target's face that faces the anchor. The connector always starts at the anchor so a - /// consumer can verify both the external and internal connectors reach the same shared point. - /// - /// The shared boundary anchor the connector starts at. - /// The boundary face the anchor lies on. - /// The interior target rectangle (a nested port anchor is a zero-size rect). - /// The orthogonal waypoints from the anchor to the interior target. - private static IReadOnlyList InteriorConnector(Point2D anchor, PortSide side, Rect target) - { - if (side is PortSide.Left or PortSide.Right) - { - var attachY = target.Y + (target.Height / 2.0); - var attachX = anchor.X <= target.X ? target.X : target.X + target.Width; - var midX = (anchor.X + attachX) / 2.0; - return [anchor, new Point2D(midX, anchor.Y), new Point2D(midX, attachY), new Point2D(attachX, attachY)]; - } - - var attachXh = target.X + (target.Width / 2.0); - var attachYh = anchor.Y <= target.Y ? target.Y : target.Y + target.Height; - var midY = (anchor.Y + attachYh) / 2.0; - return [anchor, new Point2D(anchor.X, midY), new Point2D(attachXh, midY), new Point2D(attachXh, attachYh)]; - } - /// /// Maps a flow direction to the container boundary face a delegation port sits on: the face the flow /// enters from, so an external approach and an internal delegation meet head-on across the boundary. /// /// The scope's flow direction. - /// The boundary face a synthesized delegation anchor is placed on. - private static PortSide FaceForDirection(LayoutDirection direction) => direction switch + /// The boundary face a delegation anchor is placed on. + internal static PortSide FaceForDirection(LayoutDirection direction) => direction switch { LayoutDirection.Left => PortSide.Right, LayoutDirection.Down => PortSide.Top, LayoutDirection.Up => PortSide.Bottom, _ => PortSide.Left, }; - - /// Finds the reference-equal index of in , or -1. - /// The node list to search. - /// The node to locate by reference. - /// The zero-based index, or -1 when absent. - private static int IndexOfNode(IReadOnlyList nodes, LayoutGraphNode node) - { - for (var i = 0; i < nodes.Count; i++) - { - if (ReferenceEquals(nodes[i], node)) - { - return i; - } - } - - return -1; - } - - /// Returns whether a point lies (within tolerance) on the boundary rectangle of a box. - /// The point's X coordinate. - /// The point's Y coordinate. - /// The box whose boundary is tested. - /// when the point lies on the box boundary. - private static bool OnBoxBoundary(double x, double y, LayoutBox box) - { - var onVertical = - (Math.Abs(x - box.X) < BoundaryTolerance || Math.Abs(x - (box.X + box.Width)) < BoundaryTolerance) && - y >= box.Y - BoundaryTolerance && y <= box.Y + box.Height + BoundaryTolerance; - var onHorizontal = - (Math.Abs(y - box.Y) < BoundaryTolerance || Math.Abs(y - (box.Y + box.Height)) < BoundaryTolerance) && - x >= box.X - BoundaryTolerance && x <= box.X + box.Width + BoundaryTolerance; - return onVertical || onHorizontal; - } - - /// Returns whether two points coincide within . - /// The first point. - /// The second point. - /// when the points coincide. - private static bool SamePoint(Point2D a, Point2D b) => - Math.Abs(a.X - b.X) < BoundaryTolerance && Math.Abs(a.Y - b.Y) < BoundaryTolerance; } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs index 2f8693b..9da3d48 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/HierarchyHandling.cs @@ -14,24 +14,19 @@ namespace DemaConsulting.Rendering.Layout.Engine.Layered; /// /// /// selects the ELK-style compound-graph handling used when an edge with a -/// named boundary port crosses a container boundary. Each container is still sized bottom-up -/// (children first) and the existing per-scope leaf pass anchors the external side of every -/// boundary crossing exactly as it always has; a reconciliation step then merges each boundary -/// port's external anchor and its interior delegation targets into a single shared boundary -/// anchor carrying both labels, and routes the internal delegation connectors from that anchor to -/// the children the port feeds. The merge region the reconciliation covers is general and -/// transitive: it is the minimal footprint touched by boundary-crossing edges — not fixed at -/// two levels and not one boundary port at a time — so it follows a chain of delegation ports to -/// arbitrary depth and merges any number of independent boundary ports on one container together. -/// -/// -/// The end state this mode is designed toward is a single fully-joint pass that flattens the -/// touched hierarchy and lays every crossing out as a hierarchy-crossing dummy alongside ordinary -/// long-edge dummies (see ). The current implementation reaches the -/// same general/transitive external result by reconciliation rather than that literal -/// flattened pass; the hierarchy-crossing dummy descriptor and its combined ordering primitive -/// (BoundaryPortResolver.OrderCrossings) are exercised by unit tests and reserved for that -/// joint pass, and do not yet govern production anchor placement. +/// named boundary port crosses a container boundary. The touched hierarchy is flattened into a +/// single hierarchy-aware graph spanning every nesting level (), +/// every boundary crossing becomes a hierarchy-crossing dummy that flows through exactly the same +/// layer-assignment, crossing-minimization, placement, and orthogonal corridor routing as an +/// ordinary node (), and the fully-placed result is +/// projected back into per-scope boxes, lines, and ports (). A +/// boundary port therefore resolves to a single shared dual-label anchor whose external approaches +/// and internal delegations are all routed by the corridor router — no post-hoc reconciliation and +/// no endpoint patching. The merge region is general and transitive: it is the minimal +/// footprint touched by boundary-crossing edges — not fixed at two levels and not one boundary port +/// at a time — so it follows a chain of delegation ports to arbitrary depth and merges any number of +/// independent boundary ports on one container together. Each container is sized to fit its placed +/// interior, and that growth cascades outward through every enclosing level. /// /// internal enum HierarchyHandling @@ -41,8 +36,9 @@ internal enum HierarchyHandling /// /// Handle boundary-crossing port edges over the general/transitive merge region touched by the - /// crossings, reconciling each into a single shared dual-label boundary anchor with internal - /// delegation connectors (the reserved fully-joint flattened pass is approximated by reconciliation). + /// crossings, in a single combined pass that flattens the region, lays every crossing out as a + /// hierarchy-crossing dummy, and projects the placement back into per-scope geometry with one shared + /// dual-label anchor per port whose connectors are all corridor-router-derived. /// Recursive, } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionDecomposer.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionDecomposer.cs new file mode 100644 index 0000000..9018423 --- /dev/null +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionDecomposer.cs @@ -0,0 +1,542 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering.Layout.Engine; +using static DemaConsulting.Rendering.Layout.Engine.Layered.LayeredLayoutMetrics; + +namespace DemaConsulting.Rendering.Layout.Engine.Layered; + +/// +/// The per-scope geometry a pass projects out of a fully-placed +/// : the outermost scope's composed boxes (with every nested level +/// composed into its container box), its final connector lines, its boundary-port anchors, and the +/// canvas size. +/// +/// +/// The outermost scope's composed boxes, index-aligned with the region root level's +/// (which is the scope graph's node order), each container box +/// carrying its recursively projected interior. +/// +/// Map from each root-level direct-member node to its positional index in . +/// +/// Every connector line of the whole region in absolute coordinates: ordinary interior edges at every +/// depth, each external approach routed to its boundary port's single shared anchor, and each internal +/// delegation routed from that same anchor into the container interior. +/// +/// One per boundary port, on its container's boundary, carrying both labels. +/// The region canvas width in logical pixels. +/// The region canvas height in logical pixels. +internal sealed record DecomposedRegion( + LayoutBox[] Boxes, + Dictionary IndexOf, + IReadOnlyList Lines, + IReadOnlyList Ports, + double Width, + double Height); + +/// +/// Projects a fully-placed back into per-scope +/// // geometry at every nesting +/// level — the decomposition half of the ELK-style recursive hierarchy handling that replaces the old +/// post-hoc boundary-port reconciliation. +/// +/// +/// +/// The combined pass laid every nesting level out in one coordinated placement, so this stage never +/// re-routes or re-derives geometry: each real edge's connector is the +/// -produced polyline read straight from +/// , and each interior node is translated into its container's +/// placed box by the same offset math composes with +/// ( plus the title/content offset). +/// +/// +/// A boundary port resolves to exactly one physical anchor on its container's boundary: the placed +/// endpoint of the router polyline that lands on that face — the container-link polyline of the +/// port's external-face crossing when it has an in-scope approacher, or the parent port's delegation +/// polyline when the port is a further link in a delegation chain. Every external approach edge is +/// joined orthogonally to that anchor by concatenating its router polyline with the shared +/// container-link polyline (so a fan-in of many approaches converges through the router's own +/// orthogonal channels, never a straight diagonal), and every internal delegation edge is joined to +/// the same anchor by prepending it with at most one orthogonal corner. No connector endpoint is +/// patched onto a mean-of-targets and no diagonal shortcut is ever introduced. +/// +/// +internal static class MergeRegionDecomposer +{ + /// Tolerance, in logical pixels, for treating two placed points as coincident. + private const double PointTolerance = 1e-6; + + /// Clearance, in logical pixels, bounding a boundary-port anchor's label width. + private const double PortLabelClearance = 4.0; + + /// + /// Decomposes into the outermost scope's composed boxes, connector lines, + /// boundary-port anchors, and canvas size. + /// + /// The fully-placed recursive layout result to project. + /// The region's flow direction, used to pick each boundary face. + /// + /// The outermost scope's already-composed, already-styled boxes (the leaf pass's placed boxes with + /// each container's recursively laid-out interior composed in), index-aligned with the region root + /// level's nodes; used only as a styling and interior-structure template — every placed coordinate is + /// taken from the combined pass, not from these boxes. + /// + /// The projected per-scope geometry for the outermost scope. + /// Thrown when or is . + public static DecomposedRegion Decompose( + RecursiveLayoutResult result, + LayoutDirection direction, + IReadOnlyList rootTemplates) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(rootTemplates); + + var boundaryFace = BoundaryPortResolver.FaceForDirection(direction); + + // Pass 1: resolve every boundary port's single shared anchor from the placed router polylines. + var anchors = new Dictionary(); + ResolveAnchors(result, result.Region.Root, offsetX: 0.0, offsetY: 0.0, anchors); + + // Pass 2: project every level's boxes, lines, and ports into absolute geometry. + var lines = new List(); + var ports = new List(); + var (boxes, indexOf) = ProjectLevel( + result, + result.Region.Root, + offsetX: 0.0, + offsetY: 0.0, + rootTemplates, + boundaryFace, + anchors, + lines, + ports); + + var (width, height) = LevelFootprint(result.Levels[result.Region.Root].Graph, direction); + return new DecomposedRegion(boxes, indexOf, lines, ports, width, height); + } + + /// + /// Recursively resolves each boundary port's single shared anchor: the endpoint of the router + /// polyline that lands on the port's container face, recorded in absolute coordinates. + /// + /// The fully-placed recursive layout result. + /// The nesting level being scanned. + /// The X offset mapping this level's local coordinates into absolute space. + /// The Y offset mapping this level's local coordinates into absolute space. + /// The accumulating per-port anchor lookup, mutated in place. + private static void ResolveAnchors( + RecursiveLayoutResult result, + MergeRegionLevel level, + double offsetX, + double offsetY, + Dictionary anchors) + { + var levelGraph = result.Levels[level]; + var waypoints = WaypointsByOriginalEdge(levelGraph.Graph, offsetX, offsetY); + + for (var e = 0; e < levelGraph.EdgeRoles.Count; e++) + { + var role = levelGraph.EdgeRoles[e]; + var polyline = waypoints[e]; + if (polyline is null || polyline.Count == 0) + { + continue; + } + + switch (role.Kind) + { + case LevelEdgeKind.ContainerLink: + // The external-face crossing's synthetic dummy-to-container polyline lands on the + // container face: its endpoint is the port's single shared anchor. + anchors[role.Boundary!.Port] = polyline[^1]; + break; + + case LevelEdgeKind.InternalDelegation + when OtherEndpoint(role.Edge!, role.Boundary!.Port) is LayoutGraphPort nested: + // A chain link: the parent port's delegation polyline lands on the nested container's + // face, so its endpoint is that nested port's single shared anchor. + anchors[nested] = polyline[^1]; + break; + + default: + break; + } + } + + foreach (var (_, _, child) in level.Children) + { + var childOffset = ChildOffset(result, level, child, offsetX, offsetY); + ResolveAnchors(result, child, childOffset.X, childOffset.Y, anchors); + } + } + + /// + /// Recursively projects one nesting level into absolute boxes, appending its (and every descendant + /// level's) connector lines and boundary-port anchors to the shared and + /// accumulators. + /// + /// The fully-placed recursive layout result. + /// The nesting level to project. + /// The X offset mapping this level's local coordinates into absolute space. + /// The Y offset mapping this level's local coordinates into absolute space. + /// The styling/interior template boxes, index-aligned with this level's nodes. + /// The container boundary face every boundary port of this region sits on. + /// The resolved per-port shared anchors. + /// The accumulating connector-line list, mutated in place. + /// The accumulating boundary-port anchor list, mutated in place. + /// This level's composed boxes and the node-to-index map for the outermost scope. + private static (LayoutBox[] Boxes, Dictionary IndexOf) ProjectLevel( + RecursiveLayoutResult result, + MergeRegionLevel level, + double offsetX, + double offsetY, + IReadOnlyList templates, + PortSide boundaryFace, + Dictionary anchors, + List lines, + List ports) + { + var levelGraph = result.Levels[level]; + var graph = levelGraph.Graph; + var count = level.Nodes.Count; + var boxes = new LayoutBox[count]; + var indexOf = new Dictionary(count); + + var childByNodeIndex = new Dictionary(); + foreach (var (_, nodeIndex, child) in level.Children) + { + childByNodeIndex[nodeIndex] = child; + } + + for (var i = 0; i < count; i++) + { + var node = level.Nodes[i]; + indexOf[node] = i; + var template = templates[i]; + var (effWidth, effHeight) = ResolveSize(level, node); + var placedX = graph.AugX[i] + offsetX; + var placedY = graph.AugY[i] + offsetY; + + if (childByNodeIndex.TryGetValue(i, out var childLevel)) + { + // A boundary-port container: place its box from the combined pass and recurse into its + // child level for its interior, translated into the box's padded interior. + var box = template with { X = placedX, Y = placedY, Width = effWidth, Height = effHeight, Children = [] }; + var titleHeight = HierarchicalLayoutAlgorithm.ResolveContentOffsetHeight(node); + var childOffsetX = placedX + HierarchicalLayoutAlgorithm.ContainerPadding; + var childOffsetY = placedY + HierarchicalLayoutAlgorithm.ContainerPadding + titleHeight; + var childTemplates = template.Children.OfType().ToList(); + var (childBoxes, _) = ProjectLevel( + result, + childLevel, + childOffsetX, + childOffsetY, + childTemplates, + boundaryFace, + anchors, + lines, + ports); + boxes[i] = box with { Children = childBoxes.Cast().ToList() }; + } + else + { + // A leaf or non-boundary container: the combined pass placed its box; rigidly shift its + // styled template (and any nested interior) from the template origin to the placed + // origin, preserving the interior structure. + boxes[i] = (LayoutBox)HierarchicalLayoutAlgorithm.Translate(template, placedX - template.X, placedY - template.Y); + } + } + + ProjectEdges(result, level, offsetX, offsetY, boundaryFace, anchors, lines); + ProjectPorts(level, boxes, boundaryFace, anchors, ports); + + return (boxes, indexOf); + } + + /// + /// Projects one level's connector lines into : ordinary interior edges, + /// each external approach concatenated with its shared container-link onto the boundary anchor, and + /// each internal delegation prepended orthogonally from that same anchor. + /// + /// The fully-placed recursive layout result. + /// The level whose edges are projected. + /// The X offset mapping this level's local coordinates into absolute space. + /// The Y offset mapping this level's local coordinates into absolute space. + /// The container boundary face boundary ports sit on. + /// The resolved per-port shared anchors. + /// The accumulating connector-line list, mutated in place. + private static void ProjectEdges( + RecursiveLayoutResult result, + MergeRegionLevel level, + double offsetX, + double offsetY, + PortSide boundaryFace, + Dictionary anchors, + List lines) + { + var levelGraph = result.Levels[level]; + var graph = levelGraph.Graph; + var waypoints = WaypointsByOriginalEdge(graph, offsetX, offsetY); + + // The shared container-link polyline per boundary port (external approaches concatenate onto it). + var containerLinks = new Dictionary>(); + for (var e = 0; e < levelGraph.EdgeRoles.Count; e++) + { + if (levelGraph.EdgeRoles[e].Kind == LevelEdgeKind.ContainerLink && waypoints[e] is { } link) + { + containerLinks[levelGraph.EdgeRoles[e].Boundary!.Port] = link; + } + } + + for (var e = 0; e < levelGraph.EdgeRoles.Count; e++) + { + var role = levelGraph.EdgeRoles[e]; + var polyline = waypoints[e]; + if (polyline is null || polyline.Count == 0) + { + continue; + } + + switch (role.Kind) + { + case LevelEdgeKind.Ordinary: + lines.Add(StyledLine(polyline, role.Edge!)); + break; + + case LevelEdgeKind.ExternalApproach: + var full = containerLinks.TryGetValue(role.Boundary!.Port, out var containerLink) + ? Concatenate(polyline, containerLink) + : polyline; + lines.Add(StyledLine(full, role.Edge!)); + break; + + case LevelEdgeKind.InternalDelegation when anchors.TryGetValue(role.Boundary!.Port, out var anchor): + lines.Add(StyledLine(PrependAnchor(anchor, polyline, boundaryFace), role.Edge!)); + break; + + case LevelEdgeKind.ContainerLink: + // Consumed by the external approach concatenation; carries no visible connector. + break; + + default: + break; + } + } + } + + /// + /// Emits one per boundary port discovered directly in , + /// positioned on its container box's boundary and carrying both labels. + /// + /// The level whose directly-discovered boundary ports are emitted. + /// This level's composed boxes, index-aligned with its nodes. + /// The container boundary face boundary ports sit on. + /// The resolved per-port shared anchors. + /// The accumulating boundary-port anchor list, mutated in place. + private static void ProjectPorts( + MergeRegionLevel level, + LayoutBox[] boxes, + PortSide boundaryFace, + Dictionary anchors, + List ports) + { + foreach (var boundary in level.BoundaryPorts) + { + if (!anchors.TryGetValue(boundary.Port, out var anchor) || + !level.NodeIndex.TryGetValue(boundary.Container, out var containerIndex)) + { + continue; + } + + var containerBox = boxes[containerIndex]; + var maxLabelWidth = Math.Max(0.0, (containerBox.Width / 2.0) - PortLabelClearance); + ports.Add(new LayoutPort( + anchor.X, + anchor.Y, + boundaryFace, + boundary.Port.ExternalLabel, + boundary.Port.InternalLabel, + maxLabelWidth, + SourcePort: boundary.Port)); + } + } + + /// + /// Builds the per-original-edge routed polylines of , each translated by the + /// level offset and reversed when the acyclic edge was a reversed back edge, indexed by input-edge + /// index (so it aligns with the level's ). + /// + /// The placed level graph whose routed polylines are recovered. + /// The X offset applied to every waypoint. + /// The Y offset applied to every waypoint. + /// One translated polyline per input edge (a slot is when the edge had no routed polyline). + private static IReadOnlyList?[] WaypointsByOriginalEdge(LayeredGraph graph, double offsetX, double offsetY) + { + var byEdge = new IReadOnlyList?[graph.Edges.Count]; + for (var k = 0; k < graph.Acyclic.Count; k++) + { + var originalEdge = graph.AcyclicOriginalIndex[k]; + if (originalEdge < 0 || originalEdge >= byEdge.Length) + { + continue; + } + + var polyline = graph.Waypoints[k]; + var reversed = graph.AcyclicReversed.Length > k && graph.AcyclicReversed[k]; + var translated = new List(polyline.Count); + for (var p = 0; p < polyline.Count; p++) + { + var point = reversed ? polyline[polyline.Count - 1 - p] : polyline[p]; + translated.Add(new Point2D(point.X + offsetX, point.Y + offsetY)); + } + + byEdge[originalEdge] = translated; + } + + return byEdge; + } + + /// Builds a from a polyline, styled from its originating input edge. + /// The line's absolute waypoints. + /// The originating input edge supplying the target marker, stroke style, and label. + /// The styled connector line. + private static LayoutLine StyledLine(IReadOnlyList waypoints, LayoutGraphEdge edge) => + new(waypoints, EndMarkerStyle.None, edge.TargetEnd, edge.LineStyle, edge.Label); + + /// + /// Concatenates two orthogonal polylines end-to-start, dropping the duplicated shared point so the + /// join stays a single continuous orthogonal path. + /// + /// The leading polyline (its endpoint is the shared join point). + /// The trailing polyline (its start is the shared join point). + /// The concatenated polyline. + private static List Concatenate(IReadOnlyList first, IReadOnlyList second) + { + var result = new List(first.Count + second.Count); + result.AddRange(first); + var start = result.Count > 0 && second.Count > 0 && SamePoint(result[^1], second[0]) ? 1 : 0; + for (var i = start; i < second.Count; i++) + { + result.Add(second[i]); + } + + return result; + } + + /// + /// Prepends a boundary anchor to a delegation polyline, inserting at most one orthogonal corner so + /// the connector reaches the anchor without a diagonal segment. + /// + /// The single shared boundary anchor the connector must start from. + /// The router-produced delegation polyline (starting just inside the container). + /// The container face the anchor sits on, deciding the corner orientation. + /// The delegation polyline with the anchor (and any orthogonal corner) prepended. + private static List PrependAnchor(Point2D anchor, IReadOnlyList polyline, PortSide boundaryFace) + { + var result = new List { anchor }; + var start = polyline[0]; + var alignedX = Math.Abs(anchor.X - start.X) <= PointTolerance; + var alignedY = Math.Abs(anchor.Y - start.Y) <= PointTolerance; + if (!alignedX && !alignedY) + { + // Enter perpendicular to the face: a horizontal face turns vertically first, a vertical face + // turns horizontally first, so both the anchor segment and the corner segment stay axis-aligned. + var corner = boundaryFace is PortSide.Left or PortSide.Right + ? new Point2D(start.X, anchor.Y) + : new Point2D(anchor.X, start.Y); + result.Add(corner); + } + + var skipFirst = SamePoint(result[^1], start) ? 1 : 0; + for (var i = skipFirst; i < polyline.Count; i++) + { + result.Add(polyline[i]); + } + + return result; + } + + /// Returns the endpoint of that is not . + /// The delegation edge. + /// The boundary port whose opposite endpoint is required. + /// The edge endpoint opposite . + private static ILayoutConnectable OtherEndpoint(LayoutGraphEdge edge, LayoutGraphPort port) => + ReferenceEquals(edge.Target, port) ? edge.Source : edge.Target; + + /// + /// Computes the absolute offset that maps a boundary container's child-level local coordinates into + /// the container's placed, padded (and title-offset) interior. + /// + /// The fully-placed recursive layout result. + /// The parent level owning the container. + /// The child level whose offset is computed. + /// The parent level's own X offset. + /// The parent level's own Y offset. + /// The absolute offset of the child level's local origin. + private static (double X, double Y) ChildOffset( + RecursiveLayoutResult result, + MergeRegionLevel level, + MergeRegionLevel child, + double offsetX, + double offsetY) + { + var nodeIndex = level.Children.First(entry => ReferenceEquals(entry.Child, child)).NodeIndex; + var container = level.Nodes[nodeIndex]; + var graph = result.Levels[level].Graph; + var titleHeight = HierarchicalLayoutAlgorithm.ResolveContentOffsetHeight(container); + return ( + graph.AugX[nodeIndex] + offsetX + ContainerPaddingValue, + graph.AugY[nodeIndex] + offsetY + ContainerPaddingValue + titleHeight); + } + + /// Convenience accessor for the shared container padding constant. + private static double ContainerPaddingValue => HierarchicalLayoutAlgorithm.ContainerPadding; + + /// + /// Resolves the effective bounding-box size of for , + /// falling back to the node's own dimensions when it is absent from the level's effective-size lookup. + /// + /// The level whose effective-size lookup is consulted. + /// The node whose size is required. + /// The effective width and height the node was placed with. + private static (double Width, double Height) ResolveSize(MergeRegionLevel level, LayoutGraphNode node) => + level.EffectiveSize.TryGetValue(node, out var size) ? size : (node.Width, node.Height); + + /// + /// Computes a level's placed footprint the same way derives + /// a leaf pass's canvas: the along-axis extent from the column geometry and the cross-axis extent + /// from the placed node coordinates, both padded. + /// + /// The placed level graph. + /// The flow direction the level was laid out along. + /// The level's total width and height in logical pixels. + internal static (double Width, double Height) LevelFootprint(LayeredGraph graph, LayoutDirection direction) + { + if (graph.ColumnX.Length == 0) + { + return (2.0 * Padding, 2.0 * Padding); + } + + var lastLayer = graph.ColumnX.Length - 1; + var alongTotal = graph.ColumnX[lastLayer] + graph.MaxColWidth[lastLayer] + Padding; + var transposed = direction is LayoutDirection.Down or LayoutDirection.Up; + var crossTotal = Padding; + for (var i = 0; i < graph.Nodes.Count; i++) + { + var crossFar = transposed + ? graph.AugX[i] + graph.Nodes[i].Width + : graph.AugY[i] + graph.Nodes[i].Height; + crossTotal = Math.Max(crossTotal, crossFar + Padding); + } + + return transposed ? (crossTotal, alongTotal) : (alongTotal, crossTotal); + } + + /// Returns whether two points coincide within . + /// The first point. + /// The second point. + /// when the points coincide. + private static bool SamePoint(Point2D a, Point2D b) => + Math.Abs(a.X - b.X) <= PointTolerance && Math.Abs(a.Y - b.Y) <= PointTolerance; +} diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs index 109788c..516c03b 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs @@ -109,6 +109,50 @@ internal readonly record struct LevelCrossing( BoundaryPort Boundary, HierarchyCrossingFace Face); +/// +/// The semantic role of one built into a level's assembled graph, so the +/// decomposition step can project each routed polyline back into the correct kind of per-scope +/// connector without re-deriving the crossing wiring from raw node indices. +/// +internal enum LevelEdgeKind +{ + /// An ordinary interior edge between two direct-member nodes of the level. + Ordinary, + + /// An external approach edge feeding a boundary port's external-face crossing dummy. + ExternalApproach, + + /// + /// The synthetic dummy-to-container edge leading a boundary port's external-face crossing into its + /// container box; carries no visible connector but its routed polyline lands on the container face, + /// establishing the boundary port's single shared anchor. + /// + ContainerLink, + + /// An internal delegation edge from a boundary port's internal-face crossing into an interior target. + InternalDelegation, +} + +/// +/// The semantic role of one built , index-aligned with a level graph's input +/// list, so the decomposition step can map each routed polyline +/// (recovered through ) to the connector it should +/// produce and style it from its originating . +/// +/// Which kind of connector this edge represents. +/// +/// The originating input the connector is styled from, or +/// for a synthetic . +/// +/// +/// The boundary port this edge relates to for a crossing edge (external approach, container link, or +/// internal delegation), or for an edge. +/// +internal readonly record struct LevelEdgeRole( + LevelEdgeKind Kind, + LayoutGraphEdge? Edge, + BoundaryPort? Boundary); + /// /// One nesting level's assembled paired with the hierarchy-crossing dummies /// seeded into it, so the recursive layered pipeline can both tag those dummies and recover each @@ -116,9 +160,15 @@ internal readonly record struct LevelCrossing( /// /// The per-level layered graph built by . /// The hierarchy-crossing dummies seeded into , in creation order. +/// +/// The semantic role of each built edge, index-aligned with of +/// , so the decomposition step can project routed polylines back into the +/// correct per-scope connectors. +/// internal sealed record LevelLayeredGraph( LayeredGraph Graph, - IReadOnlyList Crossings); + IReadOnlyList Crossings, + IReadOnlyList EdgeRoles); /// /// Assembles the combined, hierarchy-aware node/edge model for one merge region from the boundary ports @@ -227,9 +277,8 @@ private static void BuildAllLevelGraphs( /// /// /// Package-private so the recursive CrossingMinimizer/LayerAssigner entry points - /// (later stages) can rebuild each nested level's graph on demand. The crossing dummy wiring is - /// the input-graph precedent set by : a boundary - /// port becomes a zero-size dummy that the pipeline lays out like any other node. The + /// (later stages) can rebuild each nested level's graph on demand. Each boundary-crossing edge + /// is represented by a zero-size dummy node that the pipeline lays out like any other node. The /// descriptor itself lives on and is /// populated by the pipeline's augmentation stages, so it is not carried on the input /// here. @@ -260,6 +309,7 @@ internal static LevelLayeredGraph BuildLevelGraph(MergeRegionLevel level, Layout } var edges = new List(); + var edgeRoles = new List(); // Ordinary interior edges connect two direct-member nodes of this level. foreach (var edge in level.Edges) @@ -269,6 +319,7 @@ internal static LevelLayeredGraph BuildLevelGraph(MergeRegionLevel level, Layout if (source >= 0 && target >= 0) { edges.Add(new LayerEdge(source, target)); + edgeRoles.Add(new LevelEdgeRole(LevelEdgeKind.Ordinary, edge, Boundary: null)); } } @@ -278,17 +329,17 @@ internal static LevelLayeredGraph BuildLevelGraph(MergeRegionLevel level, Layout // dummy (its internal face) feeding the interior delegation targets it reaches. foreach (var incoming in level.IncomingBoundaries) { - AddIncomingCrossing(level, incoming, nodes, edges, crossings); + AddIncomingCrossing(level, incoming, nodes, edges, edgeRoles, crossings); } // The outgoing crossings: each boundary-port container's external approach edges feed a zero-size // dummy (its external face) that then leads into the container's own box. foreach (var boundary in level.BoundaryPorts) { - AddOutgoingCrossing(level, boundary, nodes, edges, crossings); + AddOutgoingCrossing(level, boundary, nodes, edges, edgeRoles, crossings); } - return new LevelLayeredGraph(new LayeredGraph(nodes, edges, direction), crossings); + return new LevelLayeredGraph(new LayeredGraph(nodes, edges, direction), crossings, edgeRoles); } /// @@ -387,12 +438,14 @@ private static MergeRegionLevel BuildLevel( /// The parent boundary port delegating into the level. /// The working layer-node list, appended to in place. /// The working layer-edge list, appended to in place. + /// The working edge-role list, appended to in place index-aligned with . /// The working crossing-metadata list, appended to in place. private static void AddIncomingCrossing( MergeRegionLevel level, BoundaryPort incoming, List nodes, List edges, + List edgeRoles, List crossings) { var dummyIndex = nodes.Count; @@ -414,6 +467,7 @@ private static void AddIncomingCrossing( } edges.Add(new LayerEdge(dummyIndex, target)); + edgeRoles.Add(new LevelEdgeRole(LevelEdgeKind.InternalDelegation, edge, incoming)); } } @@ -425,12 +479,14 @@ private static void AddIncomingCrossing( /// The boundary port whose external approach is being wired. /// The working layer-node list, appended to in place. /// The working layer-edge list, appended to in place. + /// The working edge-role list, appended to in place index-aligned with . /// The working crossing-metadata list, appended to in place. private static void AddOutgoingCrossing( MergeRegionLevel level, BoundaryPort boundary, List nodes, List edges, + List edgeRoles, List crossings) { if (!level.NodeIndex.TryGetValue(boundary.Container, out var containerIndex)) @@ -455,11 +511,13 @@ private static void AddOutgoingCrossing( { nodes.Add(new LayerNode(0.0, 0.0, RealWidth: 0.0, RealHeight: 0.0)); edges.Add(new LayerEdge(dummyIndex, containerIndex)); + edgeRoles.Add(new LevelEdgeRole(LevelEdgeKind.ContainerLink, Edge: null, boundary)); crossings.Add(new LevelCrossing(dummyIndex, boundary, HierarchyCrossingFace.External)); wired = true; } edges.Add(new LayerEdge(source, dummyIndex)); + edgeRoles.Add(new LevelEdgeRole(LevelEdgeKind.ExternalApproach, edge, boundary)); } } diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index d8552ff..a797596 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -113,7 +113,21 @@ public sealed class HierarchicalLayoutAlgorithm : ILayoutAlgorithm /// its children. Sizing each container to its children plus this padding keeps nested content from /// touching the container edge. /// - private const double ContainerPadding = 12.0; + internal const double ContainerPadding = 12.0; + + /// + /// Maximum number of cascading-sizing re-runs the combined pass performs before accepting the + /// current sizes. Growth only ever propagates outward through the finite nesting chain, so a stable + /// fixpoint is reached in at most chain-depth iterations; this cap is a safety bound well above any + /// realistic nesting depth. + /// + private const int MaxSizingIterations = 32; + + /// + /// Slack, in logical pixels, a container's placed interior may exceed its current effective size by + /// before the cascading-sizing pass grows it, so sub-pixel rounding never triggers a spurious re-run. + /// + private const double SizingTolerance = 0.5; /// /// Default height, in logical pixels, of the title band reserved at the top of a container that @@ -142,7 +156,7 @@ private static double ResolveTitleHeight(LayoutGraphNode node) => /// /// The container node whose reserved content offset is resolved. /// The total reserved band height in logical pixels. - private static double ResolveContentOffsetHeight(LayoutGraphNode node) + internal static double ResolveContentOffsetHeight(LayoutGraphNode node) { var titleHeight = ResolveTitleHeight(node); if (node.Shape != BoxShape.Folder) @@ -285,41 +299,127 @@ private LayoutTree LayoutScope(LayoutGraph graph, LayoutOptions effective) var (composed, indexOf) = ComposeBoxes(graph, placedBoxes, subLayouts); - var crossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); - // Resolve any boundary (delegation) ports of this scope's direct-member containers: an edge // referencing such a port crosses the container boundary and cannot be routed by an ordinary - // per-scope leaf pass. When at least one is present, the boundary-port resolver reconciles the - // leaf pass's external anchor with the container's own placed interior into a single shared - // anchor carrying both labels, and wires each internal delegation edge to it. Gating on a - // non-empty result keeps every boundary-port-free scope on its existing, byte-identical path. + // per-scope leaf pass. Gating on a non-empty discovery keeps every boundary-port-free scope on + // its existing, byte-identical path. var boundaryPorts = HierarchyMergeRegionBuilder.Collect(graph); - IReadOnlyList resolvedPorts = placedPorts; - IReadOnlyList resolvedLines = placedLines; - if (boundaryPorts.Count > 0) + + if (boundaryPorts.Count == 0) { - var resolution = BoundaryPortResolver.Resolve( - graph, - ToEngineDirection(effective.Get(CoreOptions.Direction)), - boundaryPorts, - composed, - indexOf, - placedPorts, - placedLines); - resolvedPorts = resolution.Ports; - resolvedLines = resolution.Lines; + // Hard invariant: a hierarchical scope with containers but zero boundary ports keeps its + // existing leaf-pass + cross-container-router output, byte-for-byte unchanged. + var leafCrossLines = RouteCrossContainerEdges(routedEdges, composed, indexOf, descendantToDirect, effective); + var leafNodes = new List(composed.Length + placedLines.Count + placedPorts.Count + leafCrossLines.Count); + leafNodes.AddRange(composed); + leafNodes.AddRange(placedLines); + leafNodes.AddRange(placedPorts); + leafNodes.AddRange(leafCrossLines); + return new LayoutTree(placed.Width, placed.Height, leafNodes); } - // Assemble: composed boxes, then the leaf algorithm's routed lines and any LayoutPort it - // emitted for a leaf-routed port edge (both possibly reconciled by the boundary-port resolver), - // then the cross-container lines. The canvas dimensions are the leaf algorithm's for this - // (sized) level. - var nodes = new List(composed.Length + resolvedLines.Count + resolvedPorts.Count + crossLines.Count); - nodes.AddRange(composed); - nodes.AddRange(resolvedLines); - nodes.AddRange(resolvedPorts); + // Single combined pass (ELK-style recursive hierarchy handling): assemble the hierarchy-aware + // graph spanning every nesting level, lay it all out in one coordinated placement (growing each + // container to fit its placed interior and cascading that growth outward), then project the + // placed result back into per-scope geometry. The boxes the leaf pass placed above are reused + // only as styling/interior templates; every placed coordinate and every connector polyline comes + // from the combined pass, so a fan-in of boundary approaches routes through the corridor router's + // own orthogonal channels rather than being patched onto a shared anchor. + var engineDirection = ToEngineDirection(effective.Get(CoreOptions.Direction)); + var region = MergeRegionGraphAssembler.Assemble(graph, boundaryPorts, effectiveSize); + var placement = RunRecursiveWithCascadingSizing(region, effectiveSize, engineDirection); + var decomposed = MergeRegionDecomposer.Decompose(placement, engineDirection, composed); + + var crossLines = RouteCrossContainerEdges(routedEdges, decomposed.Boxes, decomposed.IndexOf, descendantToDirect, effective); + + var nodes = new List( + decomposed.Boxes.Length + decomposed.Lines.Count + decomposed.Ports.Count + crossLines.Count); + nodes.AddRange(decomposed.Boxes); + nodes.AddRange(decomposed.Lines); + nodes.AddRange(decomposed.Ports); nodes.AddRange(crossLines); - return new LayoutTree(placed.Width, placed.Height, nodes); + return new LayoutTree(decomposed.Width, decomposed.Height, nodes); + } + + /// + /// Runs the recursive combined pass over , then cascades container sizing: + /// after each placement, any container whose placed interior needs more room than its current + /// effective size is grown, and — because a grown container enlarges the interior of every level that + /// encloses it — the pass is re-run until every container's size is stable. The common case where the + /// first placement already fits every container skips the re-run entirely, mirroring + /// 's Fix-5 two-pass growth detection generalized to cascade + /// across the whole nesting chain. + /// + /// The assembled merge region to place; its levels share . + /// + /// The mutable effective-size lookup threaded through every level of ; + /// grown in place as inner content demands more room so the next re-run reserves the larger box. + /// + /// The region's flow direction. + /// The fully-placed, size-stable recursive layout result. + private static RecursiveLayoutResult RunRecursiveWithCascadingSizing( + AssembledMergeRegion region, + Dictionary effectiveSize, + LayoutDirection direction) + { + var pipeline = LayeredLayoutPipeline.Builder() + .Direction(direction) + .Hierarchy(Engine.Layered.HierarchyHandling.Recursive) + .AddRecursiveStages() + .Build(); + + // Every (container, its placed child level) pair whose interior determines the container's size. + var sizingPairs = new List<(LayoutGraphNode Container, MergeRegionLevel Child)>(); + CollectSizingPairs(region.Root, sizingPairs); + + var result = pipeline.RunRecursive(region); + for (var iteration = 0; iteration < MaxSizingIterations; iteration++) + { + var grew = false; + foreach (var (container, child) in sizingPairs) + { + var (footprintWidth, footprintHeight) = MergeRegionDecomposer.LevelFootprint( + result.Levels[child].Graph, direction); + var titleHeight = ResolveContentOffsetHeight(container); + var requiredWidth = footprintWidth + (2 * ContainerPadding); + var requiredHeight = footprintHeight + (2 * ContainerPadding) + titleHeight; + var current = effectiveSize.TryGetValue(container, out var size) ? size : (Width: 0.0, Height: 0.0); + if (requiredWidth > current.Width + SizingTolerance || requiredHeight > current.Height + SizingTolerance) + { + effectiveSize[container] = ( + Math.Max(current.Width, requiredWidth), + Math.Max(current.Height, requiredHeight)); + grew = true; + } + } + + if (!grew) + { + break; + } + + result = pipeline.RunRecursive(region); + } + + return result; + } + + /// + /// Collects every boundary-port container of and its descendants, paired + /// with the child level whose placed footprint sizes it, so cascading sizing can measure each in a + /// single sweep. + /// + /// The nesting level to walk. + /// The accumulating (container, child level) list, mutated in place. + private static void CollectSizingPairs( + MergeRegionLevel level, + List<(LayoutGraphNode Container, MergeRegionLevel Child)> pairs) + { + foreach (var (container, _, child) in level.Children) + { + pairs.Add((container, child)); + CollectSizingPairs(child, pairs); + } } /// @@ -804,7 +904,7 @@ private static void MapDescendants( /// Offset added to every X coordinate, in logical pixels. /// Offset added to every Y coordinate, in logical pixels. /// A translated copy of , or the node unchanged for unknown subtypes. - private static LayoutNode Translate(LayoutNode node, double deltaX, double deltaY) + internal static LayoutNode Translate(LayoutNode node, double deltaX, double deltaY) { switch (node) { diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs index 9334423..8d8d991 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/BoundaryPortResolverTests.cs @@ -8,280 +8,61 @@ namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; /// -/// Tests for : the combined-pass ordering primitive -/// () and the per-scope -/// reconciliation that enriches a leaf anchor with both -/// labels and wires internal delegation connectors to one shared anchor. +/// Tests for : the direction-to-boundary-face +/// mapping the recursive hierarchy handling shares between its anchor placement and its orthogonal +/// boundary joins. The former reconciliation entry points (Resolve, OrderCrossings, +/// and their helpers) were removed when the single combined pass +/// ( → +/// ) replaced post-hoc reconciliation, so their tests were +/// retired with them; the end-to-end behavior they covered is now proven by the +/// MergeRegionDecomposer and HierarchicalLayoutAlgorithm boundary-port tests. /// public sealed class BoundaryPortResolverTests { /// - /// Ordering several hierarchy crossings that all delegate to interior targets returns a - /// deterministic permutation of the crossing indices — proof the crossings participate in the - /// combined layered pass rather than colliding at one point. + /// A rightward flow enters a container from its left face, so a delegation anchor sits on the + /// left boundary where the external approach and internal delegation meet head-on. /// [Fact] - public void OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices() + public void FaceForDirection_Right_ReturnsLeftFace() { - // Arrange: three zero-size hierarchy crossings each delegating to a sized interior target. - var crossings = new List - { - new(new LayoutGraphPort("x0"), HierarchyCrossingFace.Internal), - new(new LayoutGraphPort("x1"), HierarchyCrossingFace.Internal), - new(new LayoutGraphPort("x2"), HierarchyCrossingFace.Internal), - }; - var targets = new List<(double Width, double Height)> { (80, 40), (80, 40), (80, 40) }; - // Act - var order = BoundaryPortResolver.OrderCrossings(crossings, targets, LayoutDirection.Right); - - // Assert: a permutation of {0,1,2}. - Assert.Equal(3, order.Count); - Assert.Equal([0, 1, 2], order.OrderBy(i => i).ToList()); - } - - /// - /// Ordering crossings with no interior targets falls back to input order (nothing to order - /// against), so an empty region degrades gracefully. - /// - [Fact] - public void OrderCrossings_NoTargets_ReturnsInputOrder() - { - // Arrange: two crossings, no targets. - var crossings = new List - { - new(new LayoutGraphPort("x0"), HierarchyCrossingFace.Internal), - new(new LayoutGraphPort("x1"), HierarchyCrossingFace.Internal), - }; - - // Act - var order = BoundaryPortResolver.OrderCrossings(crossings, [], LayoutDirection.Right); + var face = BoundaryPortResolver.FaceForDirection(LayoutDirection.Right); // Assert - Assert.Equal([0, 1], order); + Assert.Equal(PortSide.Left, face); } - /// - /// Ordering an empty crossing set returns an empty result. - /// + /// A leftward flow enters from the right face. [Fact] - public void OrderCrossings_Empty_ReturnsEmpty() + public void FaceForDirection_Left_ReturnsRightFace() { // Act - var order = BoundaryPortResolver.OrderCrossings([], [], LayoutDirection.Right); + var face = BoundaryPortResolver.FaceForDirection(LayoutDirection.Left); // Assert - Assert.Empty(order); + Assert.Equal(PortSide.Right, face); } - /// - /// Resolving a boundary port whose external edge the leaf pass already anchored enriches that - /// single leaf anchor with the internal label (keeping the external label and the anchor - /// position) and adds one internal delegation connector starting at the anchor and reaching - /// into the interior child, so the external and internal approaches share one physical point. - /// + /// A downward flow enters from the top face. [Fact] - public void Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector() + public void FaceForDirection_Down_ReturnsTopFace() { - // Arrange: scope with sibling A and container B (port P delegates to child C). - var scope = new LayoutGraph(); - var a = scope.AddNode("A", 80, 40); - var b = scope.AddNode("B", 120, 80); - var port = b.Ports.AddPort("p"); - port.ExternalLabel = "EXT"; - port.InternalLabel = "INT"; - var c = b.Children.AddNode("C", 80, 40); - scope.AddEdge("a-p", a, port); - b.Children.AddEdge("p-c", port, c); - - var boundaryPorts = HierarchyMergeRegionBuilder.Collect(scope); - Assert.Single(boundaryPorts); - - // Composed geometry: A at the origin; B to its right; C inside B; the leaf's port anchor on - // B's left face at (200, 40); the external line ending on that anchor. - var aBox = Box(0, 0, 80, 40, "A", []); - var cBox = Box(220, 20, 80, 40, "C", []); - var bBox = Box(200, 0, 120, 80, "B", [cBox]); - var composed = new[] { aBox, bBox }; - var indexOf = new Dictionary { [a] = 0, [b] = 1 }; - - var anchor = new LayoutPort(200, 40, PortSide.Left, "EXT", SourcePort: port); - var placedPorts = new List { anchor }; - var externalLine = new LayoutLine( - [new Point2D(80, 20), new Point2D(200, 40)], - EndMarkerStyle.None, - EndMarkerStyle.FilledArrow, - LineStyle.Solid, - null); - var placedLines = new List { externalLine }; - // Act - var resolution = BoundaryPortResolver.Resolve( - scope, - LayoutDirection.Right, - boundaryPorts, - composed, - indexOf, - placedPorts, - placedLines); - - // Assert: one port carrying BOTH labels at the original anchor position. - var emitted = Assert.Single(resolution.Ports); - Assert.Equal("EXT", emitted.ExternalLabel); - Assert.Equal("INT", emitted.InternalLabel); - Assert.Equal(200, emitted.CentreX, 3); - Assert.Equal(40, emitted.CentreY, 3); - Assert.Equal(PortSide.Left, emitted.Side); + var face = BoundaryPortResolver.FaceForDirection(LayoutDirection.Down); - // The external line still ends at the anchor, and a new internal connector starts at it. - var anchorPoint = new Point2D(200, 40); - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], anchorPoint)); - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], anchorPoint) && - line.Waypoints.Any(wp => wp.X >= cBox.X - 1 && wp.X <= cBox.X + cBox.Width + 1)); + // Assert + Assert.Equal(PortSide.Top, face); } - /// - /// Regression for the boundary-port identity bug (isolated at the resolver level, without the - /// full pipeline): two distinct boundary ports - /// p/q on one container, both with - /// left , each already anchored by the (hand-built) leaf pass and tagged - /// with the correct . Before the fix, - /// matched a boundary port to its leaf anchor by - /// string.Equals on ExternalLabel, so both null-labeled ports would match the same - /// (first) leaf anchor, silently merging both ports' external connectors onto one anchor. This - /// test proves each port resolves independently — its own anchor position, its own external - /// line, its own internal connector — with no cross-wiring, even though ExternalLabel is - /// identical (null) on both. - /// + /// An upward flow enters from the bottom face. [Fact] - public void Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity() + public void FaceForDirection_Up_ReturnsBottomFace() { - // Arrange: scope with two siblings A1/A2 and container B owning two boundary ports p/q, both - // with a null ExternalLabel, each delegating to its own child C1/C2. - var scope = new LayoutGraph(); - var a1 = scope.AddNode("A1", 80, 40); - var a2 = scope.AddNode("A2", 80, 40); - var b = scope.AddNode("B", 120, 120); - var p = b.Ports.AddPort("p"); - p.InternalLabel = "P_IN"; - var q = b.Ports.AddPort("q"); - q.InternalLabel = "Q_IN"; - var c1 = b.Children.AddNode("C1", 80, 40); - var c2 = b.Children.AddNode("C2", 80, 40); - scope.AddEdge("a1-p", a1, p); - scope.AddEdge("a2-q", a2, q); - b.Children.AddEdge("p-c1", p, c1); - b.Children.AddEdge("q-c2", q, c2); - - var boundaryPorts = HierarchyMergeRegionBuilder.Collect(scope); - Assert.Equal(2, boundaryPorts.Count); - - // Composed geometry: A1/A2 to the left; B to the right; C1/C2 stacked inside B; the leaf pass's - // two anchors on B's left face, correctly tagged with their originating SourcePort. - var a1Box = Box(0, 0, 80, 40, "A1", []); - var a2Box = Box(0, 60, 80, 40, "A2", []); - var c1Box = Box(220, 20, 80, 40, "C1", []); - var c2Box = Box(220, 80, 80, 40, "C2", []); - var bBox = Box(200, 0, 120, 120, "B", [c1Box, c2Box]); - var composed = new[] { a1Box, a2Box, bBox }; - var indexOf = new Dictionary { [a1] = 0, [a2] = 1, [b] = 2 }; - - var pAnchor = new LayoutPort(200, 20, PortSide.Left, null, SourcePort: p); - var qAnchor = new LayoutPort(200, 100, PortSide.Left, null, SourcePort: q); - var placedPorts = new List { pAnchor, qAnchor }; - var a1Line = new LayoutLine( - [new Point2D(80, 20), new Point2D(200, 20)], - EndMarkerStyle.None, - EndMarkerStyle.FilledArrow, - LineStyle.Solid, - null); - var a2Line = new LayoutLine( - [new Point2D(80, 80), new Point2D(200, 100)], - EndMarkerStyle.None, - EndMarkerStyle.FilledArrow, - LineStyle.Solid, - null); - var placedLines = new List { a1Line, a2Line }; - // Act - var resolution = BoundaryPortResolver.Resolve( - scope, - LayoutDirection.Right, - boundaryPorts, - composed, - indexOf, - placedPorts, - placedLines); - - // Assert: two distinct anchors at their own original positions, not collapsed onto one. - Assert.Equal(2, resolution.Ports.Count); - var pEmitted = Assert.Single(resolution.Ports, port => port.InternalLabel == "P_IN"); - var qEmitted = Assert.Single(resolution.Ports, port => port.InternalLabel == "Q_IN"); - Assert.Equal(200, pEmitted.CentreX, 3); - Assert.Equal(20, pEmitted.CentreY, 3); - Assert.Equal(200, qEmitted.CentreX, 3); - Assert.Equal(100, qEmitted.CentreY, 3); + var face = BoundaryPortResolver.FaceForDirection(LayoutDirection.Up); - // p's anchor: A1's external line still ends there, and its own internal connector reaches C1 — - // but A2's external line must NOT have been re-terminated onto it. - var pPoint = new Point2D(pEmitted.CentreX, pEmitted.CentreY); - var qPoint = new Point2D(qEmitted.CentreX, qEmitted.CentreY); - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], pPoint) && - Same(line.Waypoints[0], new Point2D(80, 20))); - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], pPoint) && - line.Waypoints.Any(wp => wp.X >= c1Box.X - 1 && wp.X <= c1Box.X + c1Box.Width + 1)); - Assert.DoesNotContain( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], pPoint) && - Same(line.Waypoints[0], new Point2D(80, 80))); - - // q's anchor: A2's external line still ends there, and its own internal connector reaches C2 — - // but A1's external line must NOT have been re-terminated onto it. - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], qPoint) && - Same(line.Waypoints[0], new Point2D(80, 80))); - Assert.Contains( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[0], qPoint) && - line.Waypoints.Any(wp => wp.X >= c2Box.X - 1 && wp.X <= c2Box.X + c2Box.Width + 1)); - Assert.DoesNotContain( - resolution.Lines, - line => line.Waypoints.Count > 0 && Same(line.Waypoints[^1], qPoint) && - Same(line.Waypoints[0], new Point2D(80, 20))); + // Assert + Assert.Equal(PortSide.Bottom, face); } - - /// - /// Builds a with the minimum shape metadata for a resolver test. - /// - /// The box left coordinate. - /// The box top coordinate. - /// The box width. - /// The box height. - /// The box label. - /// The box children. - /// The constructed box. - private static LayoutBox Box( - double x, - double y, - double width, - double height, - string label, - IReadOnlyList children) => - new(x, y, width, height, label, 0, BoxShape.Rectangle, [], children); - - /// Returns whether two points coincide within a small tolerance. - /// The first point. - /// The second point. - /// when the points coincide. - private static bool Same(Point2D a, Point2D b) => - Math.Abs(a.X - b.X) < 1e-6 && Math.Abs(a.Y - b.Y) < 1e-6; } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs new file mode 100644 index 0000000..5fc8573 --- /dev/null +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs @@ -0,0 +1,161 @@ +// +// Copyright (c) DemaConsulting. All rights reserved. +// + +using DemaConsulting.Rendering; +using DemaConsulting.Rendering.Layout.Engine.Layered; + +namespace DemaConsulting.Rendering.Layout.Tests.Engine.Layered; + +/// +/// Full-waypoint orthogonality tests for the boundary-port decomposition +/// () exercised end-to-end through the public +/// surface. These are the genuine regression tests +/// for the shipped fan-in defect: every connector that terminates at a shared boundary anchor must +/// be strictly orthogonal along its whole polyline — every consecutive waypoint pair sharing +/// exactly one coordinate — so no edge is ever patched onto the anchor with a raw diagonal segment. +/// The old reconciliation code produced exactly such a diagonal for every fan-in approach past the +/// first; these tests fail against that code and pass against the corridor-router-derived decomposition. +/// +public sealed class MergeRegionDecomposerTests +{ + private const double OrthogonalTolerance = 1e-4; + + /// + /// Reproduces the downward fan-in showcase (Monitor and Operator both approach one + /// top-face boundary port that delegates inward to Controller's child). Every converging + /// external approach — and the internal delegation — that reaches the shared anchor must be + /// strictly orthogonal end to end, with no diagonal segment anywhere. The second converging + /// approach is precisely where the old code emitted a diagonal. + /// + [Fact] + public void MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal() + { + // Arrange: the vertical (downward) external fan-in showcase. + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var monitor = graph.AddNode("monitor", 120, 50); + monitor.Label = "Monitor"; + var operatorConsole = graph.AddNode("operator", 120, 50); + operatorConsole.Label = "Operator"; + + var controller = graph.AddNode("controller", 10, 10); + controller.Label = "Controller"; + var command = controller.Ports.AddPort("command"); + command.ExternalLabel = "command"; + command.InternalLabel = "dispatch"; + + var driver = controller.Children.AddNode("driver", 120, 50); + driver.Label = "Driver"; + + graph.AddEdge("monitor-command", monitor, command); + graph.AddEdge("operator-command", operatorConsole, command); + controller.Children.AddEdge("command-driver", command, driver); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: exactly one shared anchor, and every edge reaching it is orthogonal along its whole path. + AssertEveryEdgeAtSharedAnchorIsOrthogonal(tree, expectedReachingAnchor: 3); + } + + /// + /// The fan-out mirror of the fan-in showcase: two siblings approach one left-face boundary port + /// that also delegates inward to two children (a full bidirectional fan). Every edge that meets + /// the shared anchor — the two external approaches and the two internal delegations — must be + /// strictly orthogonal end to end. The second converging approach is exactly where the old code + /// emitted a diagonal, so this genuinely captures the defect on the delegated (fan-out) side too. + /// + [Fact] + public void MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal() + { + // Arrange: the horizontal (rightward) full-fan showcase (two approaches, two delegations). + var graph = new LayoutGraph(); + + var sensor = graph.AddNode("sensor", 120, 50); + sensor.Label = "Sensor"; + var gauge = graph.AddNode("gauge", 120, 50); + gauge.Label = "Gauge"; + + var controller = graph.AddNode("controller", 10, 10); + controller.Label = "Controller"; + var command = controller.Ports.AddPort("command"); + command.ExternalLabel = "command"; + command.InternalLabel = "dispatch"; + + var driver = controller.Children.AddNode("driver", 120, 50); + driver.Label = "Driver"; + var logger = controller.Children.AddNode("logger", 120, 50); + logger.Label = "Logger"; + + graph.AddEdge("sensor-command", sensor, command); + graph.AddEdge("gauge-command", gauge, command); + controller.Children.AddEdge("command-driver", command, driver); + controller.Children.AddEdge("command-logger", command, logger); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: exactly one shared anchor, and every edge reaching it is orthogonal along its whole path. + AssertEveryEdgeAtSharedAnchorIsOrthogonal(tree, expectedReachingAnchor: 4); + } + + /// + /// Asserts the layout emits exactly one boundary anchor, that the expected number of connectors + /// touch it, and that every such connector is strictly orthogonal along its entire polyline. + /// + /// The laid-out tree. + /// The number of connectors expected to touch the shared anchor. + private static void AssertEveryEdgeAtSharedAnchorIsOrthogonal(LayoutTree tree, int expectedReachingAnchor) + { + var anchorPort = Assert.Single(tree.Nodes.OfType()); + var anchor = new Point2D(anchorPort.CentreX, anchorPort.CentreY); + + var reaching = tree.Nodes.OfType() + .Where(line => line.Waypoints.Count > 0 && + (SamePoint(line.Waypoints[0], anchor) || SamePoint(line.Waypoints[^1], anchor))) + .ToList(); + + Assert.Equal(expectedReachingAnchor, reaching.Count); + + foreach (var line in reaching) + { + AssertPolylineIsStrictlyOrthogonal(line.Waypoints); + } + } + + /// + /// Asserts every consecutive, non-degenerate waypoint pair of the polyline shares exactly one + /// coordinate — i.e. it is axis-aligned (dx≈0 XOR dy≈0) — so no segment runs diagonally. + /// + /// The connector polyline. + private static void AssertPolylineIsStrictlyOrthogonal(IReadOnlyList waypoints) + { + for (var i = 1; i < waypoints.Count; i++) + { + var dx = Math.Abs(waypoints[i].X - waypoints[i - 1].X); + var dy = Math.Abs(waypoints[i].Y - waypoints[i - 1].Y); + + // Skip degenerate (coincident) points; they are not diagonal segments. + if (dx < OrthogonalTolerance && dy < OrthogonalTolerance) + { + continue; + } + + var horizontal = dy < OrthogonalTolerance; + var vertical = dx < OrthogonalTolerance; + Assert.True( + horizontal ^ vertical, + $"Segment [{waypoints[i - 1].X},{waypoints[i - 1].Y}]->[{waypoints[i].X},{waypoints[i].Y}] " + + "is diagonal (dx and dy both non-zero) — a boundary connector was patched onto the anchor."); + } + } + + /// Returns true when two points coincide within a small tolerance. + private static bool SamePoint(Point2D a, Point2D b) + { + const double tolerance = 1e-6; + return Math.Abs(a.X - b.X) < tolerance && Math.Abs(a.Y - b.Y) < tolerance; + } +} diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index efbc5a1..22b4228 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -1276,7 +1276,110 @@ public void Apply_GraphSetHierarchical_DoesNotThrow() } /// - /// Deterministically builds a flat (non-nested) layout graph for the given seed, with random + /// End-to-end proof of an arbitrary-depth delegation chain: a sibling in the root scope reaches a + /// boundary port on an outer container, which delegates through a boundary port on a middle + /// container, which delegates through a boundary port on an inner container, which finally reaches + /// a leaf three levels deep. The whole pipeline (scope walk, assembly, combined recursive + /// placement, decomposition) must connect the outer edge through all three levels to the innermost + /// leaf using strictly orthogonal segments only — no diagonal patched onto any anchor at any depth. + /// + [Fact] + public void HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath() + { + // Arrange: source -> L1.p1 -> L2.p2 -> L3.p3 -> leaf, one boundary port per nesting level. + var graph = BuildDelegationChain(leafWidth: 120, leafHeight: 50); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: one anchor per level (three in total), each on its own container's boundary. + var ports = tree.Nodes.OfType().ToList(); + Assert.Equal(3, ports.Count); + + var l1 = tree.Nodes.OfType().Single(box => box.Label == "L1"); + var l2 = l1.Children.OfType().Single(box => box.Label == "L2"); + var l3 = l2.Children.OfType().Single(box => box.Label == "L3"); + var leaf = l3.Children.OfType().Single(box => box.Label == "Leaf"); + var source = tree.Nodes.OfType().Single(box => box.Label == "Source"); + + // Every connector, at every depth, is strictly orthogonal along its whole polyline. + var lines = tree.Nodes.OfType().ToList(); + foreach (var line in lines) + { + AssertPolylineIsStrictlyOrthogonal(line.Waypoints); + } + + // The chain is connected end to end: some connector touches the root source, and some connector + // reaches into the innermost leaf three levels down. + Assert.Contains(lines, line => TouchesBox(line, source)); + Assert.Contains(lines, line => TouchesBox(line, leaf)); + + // Each level's anchor sits on its own container's boundary — the physical hand-off between levels. + Assert.Contains(ports, port => OnBoxBoundary(port.CentreX, port.CentreY, l1)); + Assert.Contains(ports, port => OnBoxBoundary(port.CentreX, port.CentreY, l2)); + Assert.Contains(ports, port => OnBoxBoundary(port.CentreX, port.CentreY, l3)); + } + + /// + /// Hard-invariant guard: a hierarchical graph that has containers but zero boundary + /// ports must never take the new combined-pass path; its output must be byte-for-byte the leaf + /// pass + cross-container routing it produced before this change. The expected layout is the + /// leaf-composition oracle (containers sized bottom-up, cross-container edge routed by the + /// existing router); forcing the combined path instead (breaking the Collect == 0 gate) + /// changes these coordinates and fails this assertion. + /// + [Fact] + public void HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange() + { + // Arrange: two containers, each with a child, joined by a genuine cross-container edge and NO + // boundary ports anywhere — the exact shape the Collect == 0 gate must keep on the leaf path. + var graph = BuildNoBoundaryPortHierarchy(); + var options = LayoutOptions.ForAlgorithm("layered"); + + // Act + var actual = new HierarchicalLayoutAlgorithm().Apply(graph, options); + + // Assert: the layout matches the captured leaf-path golden snapshot exactly (bit level). + Assert.Equal(NoBoundaryPortGolden, DumpTree(actual)); + } + + /// + /// Cascading-sizing proof: when the innermost container of a three-level delegation chain holds a + /// large leaf, that growth must cascade outward so every enclosing level grows to physically + /// contain the level nested inside it. The innermost leaf keeps its intrinsic size, and each + /// container box strictly encloses the box nested directly within it, with room for padding. + /// + [Fact] + public void HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_GrowthCascadesThroughEveryEnclosingLevel() + { + // Arrange: an oversized innermost leaf forces the inner container to grow, which must cascade + // through the middle and outer containers. + const double leafWidth = 420; + const double leafHeight = 300; + var graph = BuildDelegationChain(leafWidth, leafHeight); + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: the innermost leaf keeps its intrinsic size. + var l1 = tree.Nodes.OfType().Single(box => box.Label == "L1"); + var l2 = l1.Children.OfType().Single(box => box.Label == "L2"); + var l3 = l2.Children.OfType().Single(box => box.Label == "L3"); + var leaf = l3.Children.OfType().Single(box => box.Label == "Leaf"); + + Assert.Equal(leafWidth, leaf.Width, precision: 3); + Assert.Equal(leafHeight, leaf.Height, precision: 3); + + // Growth cascaded outward: each level strictly encloses the box nested directly inside it. + AssertStrictlyEncloses("L3 must contain Leaf", l3, leaf); + AssertStrictlyEncloses("L2 must contain L3", l2, l3); + AssertStrictlyEncloses("L1 must contain L2", l1, l2); + + // Sanity: the oversized inner content actually forced the whole chain to be large. + Assert.True(l1.Width >= leafWidth, "Outermost container did not grow to accommodate the deep oversized leaf."); + } + + /// Deterministically builds a flat (non-nested) layout graph for the given seed, with random /// node sizes and arbitrary edges (including self-loops, parallel edges, and cycles). /// private static LayoutGraph BuildRandomFlatGraph(int seed) @@ -1314,7 +1417,204 @@ private static LayoutGraph BuildRandomFlatGraph(int seed) } /// - /// Deep-compares two layout trees for exact (bit-level) equality of every geometric field, + /// Builds a three-level delegation chain: a root Source reaches boundary port p1 on + /// container L1, which delegates to port p2 on nested container L2, which + /// delegates to port p3 on nested container L3, which finally delegates to the + /// leaf Leaf of the given intrinsic size. + /// + /// The innermost leaf's intrinsic width. + /// The innermost leaf's intrinsic height. + /// The assembled delegation-chain graph. + private static LayoutGraph BuildDelegationChain(double leafWidth, double leafHeight) + { + var graph = new LayoutGraph(); + + var source = graph.AddNode("Source", 120, 50); + source.Label = "Source"; + + var l1 = graph.AddNode("L1", 10, 10); + l1.Label = "L1"; + var p1 = l1.Ports.AddPort("p1"); + p1.ExternalLabel = "in1"; + p1.InternalLabel = "out1"; + + var l2 = l1.Children.AddNode("L2", 10, 10); + l2.Label = "L2"; + var p2 = l2.Ports.AddPort("p2"); + p2.ExternalLabel = "in2"; + p2.InternalLabel = "out2"; + + var l3 = l2.Children.AddNode("L3", 10, 10); + l3.Label = "L3"; + var p3 = l3.Ports.AddPort("p3"); + p3.ExternalLabel = "in3"; + p3.InternalLabel = "out3"; + + var leaf = l3.Children.AddNode("Leaf", leafWidth, leafHeight); + leaf.Label = "Leaf"; + + graph.AddEdge("source-p1", source, p1); + l1.Children.AddEdge("p1-p2", p1, p2); + l2.Children.AddEdge("p2-p3", p2, p3); + l3.Children.AddEdge("p3-leaf", p3, leaf); + + return graph; + } + + /// + /// Builds a hierarchical graph with two containers, each holding one child, joined by a genuine + /// cross-container edge and containing no boundary ports — the shape that must stay on + /// the leaf pass under the Collect == 0 gate. + /// + /// The assembled boundary-port-free hierarchy. + private static LayoutGraph BuildNoBoundaryPortHierarchy() + { + var graph = new LayoutGraph(); + + var left = graph.AddNode("Left", 10, 10); + left.Label = "Left"; + var leftChild = left.Children.AddNode("LeftChild", 80, 40); + leftChild.Label = "LeftChild"; + + var right = graph.AddNode("Right", 10, 10); + right.Label = "Right"; + var rightChild = right.Children.AddNode("RightChild", 80, 40); + rightChild.Label = "RightChild"; + + // A genuine cross-container edge between the two nested children (routed by the cross-container + // router, not a boundary port), plus an ordinary edge between the two container nodes so the + // scope has real layered structure that the combined pass would place differently. + graph.AddEdge("leftchild-rightchild", leftChild, rightChild); + graph.AddEdge("left-right", left, right); + + return graph; + } + + /// + /// Serializes a layout tree into a stable, human-readable string capturing every box rectangle + /// (recursively) and every line's waypoints, for exact snapshot comparison. + /// + /// The tree to serialize. + /// The deterministic textual snapshot. + private static string DumpTree(LayoutTree tree) + { + var builder = new System.Text.StringBuilder(); + builder.Append(System.Globalization.CultureInfo.InvariantCulture, $"W={tree.Width:R} H={tree.Height:R}\n"); + foreach (var node in tree.Nodes) + { + DumpNode(builder, node, 0); + } + + return builder.ToString(); + } + + /// Appends a single node (and its children) to the snapshot builder. + /// The accumulating snapshot builder. + /// The node to append. + /// The current nesting depth, used for indentation. + private static void DumpNode(System.Text.StringBuilder builder, LayoutNode node, int depth) + { + var indent = new string(' ', depth * 2); + var culture = System.Globalization.CultureInfo.InvariantCulture; + switch (node) + { + case LayoutBox box: + builder.Append(culture, $"{indent}BOX {box.Label} X={box.X:R} Y={box.Y:R} W={box.Width:R} H={box.Height:R}\n"); + foreach (var child in box.Children) + { + DumpNode(builder, child, depth + 1); + } + + break; + + case LayoutLine line: + builder.Append(culture, $"{indent}LINE"); + foreach (var wp in line.Waypoints) + { + builder.Append(culture, $" ({wp.X:R},{wp.Y:R})"); + } + + builder.Append('\n'); + break; + + case LayoutPort port: + builder.Append(culture, $"{indent}PORT {port.ExternalLabel}/{port.InternalLabel} X={port.CentreX:R} Y={port.CentreY:R}\n"); + break; + + default: + builder.Append(culture, $"{indent}{node.GetType().Name}\n"); + break; + } + } + + /// Asserts every consecutive, non-degenerate waypoint pair of the polyline is axis-aligned. + /// The connector polyline. + private static void AssertPolylineIsStrictlyOrthogonal(IReadOnlyList waypoints) + { + const double tolerance = 1e-4; + for (var i = 1; i < waypoints.Count; i++) + { + var dx = Math.Abs(waypoints[i].X - waypoints[i - 1].X); + var dy = Math.Abs(waypoints[i].Y - waypoints[i - 1].Y); + if (dx < tolerance && dy < tolerance) + { + continue; + } + + Assert.True( + (dy < tolerance) ^ (dx < tolerance), + $"Segment [{waypoints[i - 1].X},{waypoints[i - 1].Y}]->[{waypoints[i].X},{waypoints[i].Y}] is diagonal."); + } + } + + /// Returns true when either endpoint of the line lies on the given box's boundary. + /// The connector line. + /// The box to test against. + /// True when an endpoint touches the box boundary. + private static bool TouchesBox(LayoutLine line, LayoutBox box) + { + if (line.Waypoints.Count == 0) + { + return false; + } + + var start = line.Waypoints[0]; + var end = line.Waypoints[^1]; + return OnBoxBoundary(start.X, start.Y, box) || OnBoxBoundary(end.X, end.Y, box); + } + + /// Asserts the outer box strictly encloses the inner box (with room to spare on every side). + /// A message prefix for assertion failures. + /// The enclosing box. + /// The box expected to be nested within. + private static void AssertStrictlyEncloses(string context, LayoutBox outer, LayoutBox inner) + { + Assert.True(inner.X >= outer.X - 0.001, $"{context}: inner left is outside outer."); + Assert.True(inner.Y >= outer.Y - 0.001, $"{context}: inner top is outside outer."); + Assert.True( + inner.X + inner.Width <= outer.X + outer.Width + 0.001, + $"{context}: inner right is outside outer."); + Assert.True( + inner.Y + inner.Height <= outer.Y + outer.Height + 0.001, + $"{context}: inner bottom is outside outer."); + Assert.True(outer.Width > inner.Width, $"{context}: outer is not wider than inner (no room for padding)."); + Assert.True(outer.Height > inner.Height, $"{context}: outer is not taller than inner (no room for padding)."); + } + + /// + /// The captured leaf-path golden snapshot for . Any + /// change to the boundary-port-free layout path (including forcing the combined pass) alters this. + /// + private const string NoBoundaryPortGolden = + "W=184 H=326\n" + + "BOX Left X=20 Y=20 W=144 H=128\n" + + " BOX LeftChild X=52 Y=76 W=80 H=40\n" + + "BOX Right X=20 Y=178 W=144 H=128\n" + + " BOX RightChild X=52 Y=234 W=80 H=40\n" + + "LINE (32,148) (32,178)\n" + + "LINE (152,148) (152,178)\n"; + + /// Deep-compares two layout trees for exact (bit-level) equality of every geometric field, /// node kind, box attribute, and line attribute. No numeric tolerance is allowed. /// private static void AssertTreesIdentical(string context, LayoutTree expected, LayoutTree actual) From 154b4a29a12f73b5bc11b003e668e81e56e98a9b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 11:02:16 -0400 Subject: [PATCH 14/23] docs(layout): companion artifacts + deep-chain gallery for single-pass boundary ports --- .../engine/layered-pipeline.md | 82 +++++++++++++------ .../hierarchical-layout-algorithm.md | 57 ++++++++----- docs/gallery/README.md | 18 +++- .../boundary-ports-showcase-deep-chain.svg | 50 +++++++++++ .../boundary-ports-showcase-horizontal.svg | 26 +++--- .../boundary-ports-showcase-vertical.svg | 30 +++---- .../engine/layered-pipeline.yaml | 64 +++++++++++---- .../hierarchical-layout-algorithm.yaml | 27 ++++-- .../engine/layered-pipeline.md | 49 +++++++---- .../hierarchical-layout-algorithm.md | 34 ++++++-- .../GalleryCatalog.cs | 23 ++++-- .../GalleryDiagrams.cs | 71 ++++++++++++++-- .../GalleryShowcaseTests.cs | 19 ++++- 13 files changed, 417 insertions(+), 133 deletions(-) create mode 100644 docs/gallery/boundary-ports-showcase-deep-chain.svg diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index f3e79ce..13e6a24 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -44,11 +44,15 @@ shared spacing, clearance, and padding constants — intentionally identical to previous monolithic engine so the pipeline reproduces its output exactly. The `LayoutDirection` enum selects Right, Down, Left, or Up flow; `HierarchyHandling` selects `Flat` (a single flat pass) or the ELK-style `Recursive` compound-graph mode. `Recursive` is no longer a fail-fast placeholder: it -assembles a genuinely runnable stage sequence (via `AddRecursiveStages`) that the boundary-port -resolver drives to order the same-face crossings of a container's boundary (delegation) ports. To -support that ordering, `AugNode` can carry an optional `HierarchyCrossing` descriptor recording which -container face a crossing occupies; the descriptor defaults to none, so every flat-graph augmented node -— and therefore the flat fast path — is constructed byte-identically to before it existed. +assembles a genuinely runnable stage sequence (via `AddRecursiveStages`) that the hierarchical +algorithm drives in production (through `LayeredLayoutPipeline.RunRecursive`) to lay out a container +together with its nested descendants in one combined pass. To coordinate the container faces that share +a boundary (delegation) port, `AugNode` carries an optional `HierarchyCrossing` descriptor recording +which container face a crossing occupies; the descriptor now has a real producer — +`MergeRegionGraphAssembler` seeds one crossing per boundary face and `LayeredLayoutPipeline.TagCrossings` +sets it on the augmented node after long-edge splitting — yet still defaults to none, so every +flat-graph augmented node — and therefore the flat fast path — is constructed byte-identically to +before it existed. #### Layered Pipeline Assembly @@ -59,7 +63,13 @@ A pipeline is assembled through the fluent `LayeredLayoutPipeline.PipelineBuilde stage sequence for `HierarchyHandling.Recursive`, so `Build` no longer rejects recursive hierarchy handling with `NotSupportedException`. `Run(graph)` first calls `AxisTransform.NormalizeInputAxes` to normalize the input node axes for the requested direction, then applies every stage in order. `Run` -rejects a null graph with `ArgumentNullException`. +rejects a null graph with `ArgumentNullException`. `RunRecursive(region)` is the recursive entry +point: it assembles every level graph of an `AssembledMergeRegion` (via `MergeRegionGraphAssembler`) +and drives the same ELK stage sequence per level — innermost first for crossing coordination — +substituting the recursive `LayerAssigner.AssignLayersRecursive` and +`CrossingMinimizer.MinimizeCrossingsRecursive` for their flat counterparts so a container's boundary +faces and its children's ordering are decided together across levels. It returns a +`RecursiveLayoutResult` carrying each level's placed graph and its boundary crossings. #### Layered Pipeline Stages @@ -116,21 +126,44 @@ or several. #### Layered Pipeline Boundary Ports -Two internal helpers in `Engine/Layered` support the hierarchical algorithm's boundary (delegation) -ports. `HierarchyMergeRegionBuilder` detects them structurally: a container's port is a boundary port -when an edge inside that container's own child scope references the port — the inward delegation edge is -the signal. Its `Collect` reports a single scope's boundary ports (ignoring same-scope ports and -leaf-node ports), and its `CollectRecursive` walks the whole hierarchy so detection is general, -transitive, and depth-unbounded. `BoundaryPortResolver` then reconciles each detected boundary port to -one shared physical anchor on the container boundary carrying both labels: it consolidates external -fan-out onto that anchor and adds one internal delegation connector per internal edge reaching into the -container's placed interior. `OrderCrossings` deterministically orders several crossings that share one -container face (returning a permutation of the input indices, and the input order when there are no -targets or none to order). Rather than a single flattened cross-scope Sugiyama pass spanning nested -scopes, the resolver *reconciles* the anchor the per-scope leaf pass already produced — enriching it -with the internal label and internal delegation connectors — driving the `Recursive` pipeline path over -the `AugNode` hierarchy-crossing descriptors only to order same-face crossings. A fully flattened joint -cross-scope pass remains a documented future refinement (see ROADMAP.md). +Several internal helpers in `Engine/Layered` implement the hierarchical algorithm's boundary +(delegation) ports through one combined recursive pass rather than a post-hoc reconciliation of +separately-placed scopes. `HierarchyMergeRegionBuilder` detects boundary ports structurally: a +container's port is a boundary port when an edge inside that container's own child scope references the +port — the inward delegation edge is the signal. Its `Collect` reports a single scope's boundary ports +(ignoring same-scope ports and leaf-node ports), and its `CollectRecursive` walks the whole hierarchy +so detection is general, transitive, and depth-unbounded. + +`MergeRegionGraphAssembler` assembles a detected container and all of its nested descendants into an +`AssembledMergeRegion` of per-level graphs, flattening every interior node into its level and seeding +one zero-size crossing dummy per boundary face (an incoming `Internal`-face dummy that relays inward to +each internal delegation target, and an outgoing `External`-face dummy between the interior approachers +and the container node). It records each seeded crossing as a `LevelCrossing` so the boundary faces +carry their `HierarchyCrossing(port, face)` identity through the pipeline; a multi-port container yields +exactly one child level whose incoming boundaries are all of that container's ports. + +`RunRecursive` then drives every level through the ELK stage sequence with recursive layer assignment +and crossing minimization: `LayerAssigner.AssignLayersRecursive` assigns level-local layers per level +and recurses into children, while `CrossingMinimizer.MinimizeCrossingsRecursive` performs a genuine +two-direction hierarchical sweep — an up-sweep that seeds a parent level's same-face crossing order from +its resolved child (`Internal`-face) order, and a down-sweep that pins the child's `Internal` crossings +to the parent's resolved `External` order and re-minimizes the child interior with a forward-only +propagation. `LongEdgeSplitter` carries each pre-seeded `HierarchyCrossing` tag through the +augmented-node rebuild so a crossing dummy stays a tagged terminal hop and is never split; for the flat +path no crossing is seeded, so the rebuild is byte-identical. + +`MergeRegionDecomposer` projects the placed combined result back into per-scope geometry. It resolves +each boundary port to one shared physical anchor on the container face — the face given by +`BoundaryPortResolver.FaceForDirection`, keyed on the boundary port's reference identity so the port's +external and internal faces collapse onto one point — and takes every converging edge's waypoints +directly from the orthogonal corridor router's routed polylines (the external approach concatenates the +approach and container-link polylines; each internal delegation prepends the shared anchor to its +delegation polyline with at most one orthogonal corner). Boundary containers recurse to arbitrary depth; +leaf and non-boundary nodes rigid-shift into place. No endpoint is patched onto the anchor with a +hand-built diagonal, which is what keeps external fan-in and multi-level delegation chains orthogonal. +`BoundaryPortResolver` itself is now reduced to the single retained `FaceForDirection` helper (the +direction→face mapping the decomposer's anchor placement shares); its former reconciliation code and the +`OrderCrossings` ordering it once performed are superseded by the recursive crossing minimizer above. #### Layered Pipeline Dependencies @@ -150,8 +183,10 @@ documentation of this dependency. The pipeline is assembled and run directly by `InterconnectionLayoutEngine`, and the public `LayeredLayoutAlgorithm` consumes it transitively through that engine when the layered algorithm is -selected. The boundary-port helpers (`HierarchyMergeRegionBuilder`, `BoundaryPortResolver`) are -consumed by `HierarchicalLayoutAlgorithm` to detect and reconcile a container's boundary ports. +selected. The boundary-port helpers (`HierarchyMergeRegionBuilder`, `MergeRegionGraphAssembler`, +`RunRecursive`, and `MergeRegionDecomposer`) are consumed by `HierarchicalLayoutAlgorithm` to detect a +container's boundary ports, lay the container and its descendants out in one combined pass, and project +the result back into per-scope geometry. #### Layered Pipeline Interactions @@ -166,6 +201,7 @@ public layout result contract. | Rendering-Layout-LayeredPipeline-StagedPipeline | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-BehaviorPreserving | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-FlatHierarchyOnly | Layered pipeline behavior described above | +| Rendering-Layout-LayeredPipeline-RecursiveCombinedPass | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-BoundaryPortDetection | Layered pipeline behavior described above | | Rendering-Layout-LayeredPipeline-BoundaryPortResolution | Layered pipeline behavior described above | diff --git a/docs/design/rendering-layout/hierarchical-layout-algorithm.md b/docs/design/rendering-layout/hierarchical-layout-algorithm.md index bcb59c1..17ffeb7 100644 --- a/docs/design/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/design/rendering-layout/hierarchical-layout-algorithm.md @@ -12,8 +12,10 @@ and composing the sub-layouts into one absolute `LayoutTree`. It does not place selects a bundled *leaf* algorithm per scope and delegates the actual placement to it, then sizes each container and composes the results. It additionally resolves *boundary (delegation) ports* — a container port that both receives an external approach edge and is referenced by an edge inside the -container's own child scope — reconciling each to one shared anchor on the container boundary carrying -both an external and an internal label. It is additive: it changes no existing output and is honored only +container's own child scope — laying the container together with its nested children in a single +combined recursive layered pass so each such port resolves to one shared anchor on the container +boundary carrying both an external and an internal label, with every converging edge routed +orthogonally onto it. It is additive: it changes no existing output and is honored only when a caller selects it by name. ### HierarchicalLayoutAlgorithm Data Model @@ -86,23 +88,37 @@ overrides win over the supplied options, exactly like every nested scope), and c and the composed boxes followed by the leaf-routed lines, any leaf-emitted port anchors, and the cross-container lines. -**Boundary (delegation) port reconciliation.** After the leaf pass places a scope, `LayoutScope` +**Boundary (delegation) port combined pass.** After the leaf pass places a scope, `LayoutScope` collects that scope's boundary ports with `HierarchyMergeRegionBuilder` (part of the layered-pipeline unit, in `Engine/Layered`): a container's port is a boundary port when an edge inside that container's own `Children` references it — the inward delegation edge is the structural signal, detected transitively and to unbounded depth by a recursive collect. *Only when at least one boundary port is -detected* does the engine reconcile the leaf pass output with `BoundaryPortResolver`; a scope with no -boundary port takes the identical existing code path, so flat and ordinary port-edge graphs are -byte-for-byte unchanged. The resolver reconciles each boundary port to one shared physical anchor on -the container boundary carrying both the external label (rendered outward) and the internal label -(rendered inward), consolidates external fan-out onto that anchor, and adds one internal delegation -connector per internal edge reaching into the container's placed interior. This is a *reconciliation* -of the anchor the per-scope leaf pass already produced — enriched with the internal label and internal -delegation connectors — rather than a single flattened cross-scope Sugiyama pass; the `AugNode` -hierarchy-crossing descriptor and the recursive pipeline path order same-face crossings. A fully -flattened joint cross-scope pass remains a documented future refinement (see ROADMAP.md); the current -reconciliation satisfies every boundary-port acceptance criterion (one shared anchor carrying both -labels, external and internal fan-out, and two independent boundary ports on one container). +detected* does the engine take the combined-pass path; a scope with no boundary port takes the +identical existing code path, so flat and ordinary port-edge graphs are byte-for-byte unchanged. On +the combined path the engine assembles the container and all of its nested descendants into one +`AssembledMergeRegion` (`MergeRegionGraphAssembler`), runs it through the recursive layered pipeline +(`LayeredLayoutPipeline.RunRecursive` — recursive layer assignment and crossing minimization across +every level, one crossing dummy seeded per boundary face, each crossing tagged on its `AugNode`), and +projects the placed result back into per-scope geometry with `MergeRegionDecomposer`. The decomposer +resolves each boundary port to one shared physical anchor on the container face — the face given by +`BoundaryPortResolver.FaceForDirection`, keyed on the boundary port's reference identity so the +external and internal faces collapse onto one point — carrying both the external label (rendered +outward) and the internal label (rendered inward). Crucially, every converging edge takes its +waypoints directly from the orthogonal corridor router's routed polylines: the external approach is the +approach polyline concatenated with the container-link polyline, and each internal delegation prepends +the shared anchor to its delegation polyline with at most one orthogonal corner. No endpoint is patched +onto the anchor with a hand-built diagonal, which is what keeps external fan-in and multi-level +delegation chains free of the diagonal shortcut a post-hoc endpoint reconciliation produced. + +**Two-pass cascading container sizing.** Because a container's placed interior size is only known +after its combined pass runs, the engine sizes containers in two passes, mirroring the established +`Fix-5` growth precedent: it assembles the region once, then re-runs `RunRecursive` while any +container's placed interior footprint (`MergeRegionDecomposer.LevelFootprint` plus padding and title +height) exceeds its current effective size, growing the shared mutable effective-size map in place so +inner growth cascades outward through every enclosing level (`RunRecursiveWithCascadingSizing`, +bounded by `MaxSizingIterations` and `SizingTolerance`). The common no-growth case settles in a single +pass. `ContainerPadding` and the resolved title height are the same offsets the flat composition path +uses, reused rather than re-derived. Every `CoreOptions` property (`Algorithm`, `Direction`, `EdgeRouting`, `HierarchyHandling`, `NodeSpacing`, `LayerSpacing`) cascades through this same generalized mechanism, built once on @@ -138,8 +154,9 @@ port owned by a plain (non-container) node with an edge into a different contain throws `NotSupportedException` with a message identifying named ports crossing a container boundary as not supported; that port's owner has no child scope to delegate into, so this scope's router has no port concept for it, and broader boundary-crossing port support remains a separate future effort. A -container's own boundary (delegation) port is not this error case: it is reconciled to one shared anchor -by `BoundaryPortResolver` as described above. A same-scope port edge (neither endpoint nested relative +container's own boundary (delegation) port is not this error case: it is resolved to one shared anchor +by the combined recursive pass and its `MergeRegionDecomposer` as described above. A same-scope port +edge (neither endpoint nested relative to this scope) is likewise not an error case: it is routed locally by the leaf algorithm exactly as it would be in a flat graph. @@ -156,8 +173,10 @@ would be in a flat graph. scope's cascaded effective options snapshot. - **Layout units** — `LayeredLayoutAlgorithm` and `ContainmentLayoutAlgorithm` as bundled leaf algorithms registered in the default registry, `ConnectorRouter` for LCA cross-container edge - routing, and `HierarchyMergeRegionBuilder` / `BoundaryPortResolver` (layered-pipeline unit, - `Engine/Layered`) for boundary-port detection and reconciliation. See *ConnectorRouter Unit Design* + routing, and `HierarchyMergeRegionBuilder`, `MergeRegionGraphAssembler`, the recursive + `LayeredLayoutPipeline`, `MergeRegionDecomposer`, and `BoundaryPortResolver.FaceForDirection` + (layered-pipeline unit, `Engine/Layered`) for boundary-port detection, the combined recursive pass, + and its projection back to per-scope geometry. See *ConnectorRouter Unit Design* and *Layered Pipeline Unit Design*. No OTS runtime component or shared package is consumed. diff --git a/docs/gallery/README.md b/docs/gallery/README.md index d5523a1..536b73f 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -147,16 +147,26 @@ The companion top/bottom case: a downward-flowing hub node, whose ports anchor o The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an external and an internal label at one shared physical anchor on its boundary. An external approach edge from a sibling reaches the anchor from outside, while one or more internal delegation edges relay the connection inward to the -container's nested children. The external label reads outward (away from the container) and the internal label reads -inward, and the anchor consolidates external and internal fan-out onto a single boundary point. +container's nested children. The container and its children are laid out in one combined recursive pass, and every +converging edge — external approach and internal delegation alike — is routed through the orthogonal corridor router +onto that single shared anchor, with the external label reading outward and the internal label reading inward. ![A sibling joined to a container's boundary port delegating to two children](boundary-ports-showcase-horizontal.svg) A rightward-flowing container exposes one boundary port on its left face carrying both a 'command' external label (reading outward) and a 'dispatch' internal label (reading inward) at the same shared anchor. The external approach edge -from the sibling and both internal delegation edges (internal fan-out to two nested children) reach that one anchor. +from the sibling and both internal delegation edges (internal fan-out to two nested children) are routed orthogonally +onto that one anchor. ![Two siblings joined to a container's top-face boundary port and one child](boundary-ports-showcase-vertical.svg) The companion downward-flowing case: the boundary port anchors on the container's top face, with external fan-out (two -sibling approach edges) consolidated onto the one shared anchor, which then delegates inward to the single nested child. +sibling approach edges) both routed orthogonally onto the one shared anchor, which then delegates inward to the single +nested child. + +![A boundary port delegating through a nested boundary port to a leaf](boundary-ports-showcase-deep-chain.svg) + +A three-level delegation chain: a sibling approaches an outer container's boundary port, which delegates inward to a +nested container's own boundary port, which delegates again to the innermost leaf. Both boundary crossings carry an +outward external and an inward internal label, and the whole chain is routed orthogonally in one combined recursive pass +with no diagonal shortcut at either boundary. diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.svg b/docs/gallery/boundary-ports-showcase-deep-chain.svg new file mode 100644 index 0000000..be05ef5 --- /dev/null +++ b/docs/gallery/boundary-ports-showcase-deep-chain.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Source + + System + + Subsystem + + Core + + + + + handle + relay + + route + request + diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg index 8c1af4f..314e8c1 100644 --- a/docs/gallery/boundary-ports-showcase-horizontal.svg +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -1,4 +1,4 @@ - + @@ -32,16 +32,16 @@ Sensor - - Controller - - Driver - - Logger - - - - - dispatch - command + + Controller + + Driver + + Logger + + + + + dispatch + command diff --git a/docs/gallery/boundary-ports-showcase-vertical.svg b/docs/gallery/boundary-ports-showcase-vertical.svg index f7287f9..a0debf2 100644 --- a/docs/gallery/boundary-ports-showcase-vertical.svg +++ b/docs/gallery/boundary-ports-showcase-vertical.svg @@ -1,4 +1,4 @@ - + @@ -30,18 +30,18 @@ - - Monitor - - Operator - - Controller - - Driver - - - - - dispatch - command + + Monitor + + Operator + + Controller + + Driver + + + + + dispatch + command diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 599fd59..b3f77d9 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -41,13 +41,39 @@ sections: with an error. justification: | Flat handling reproduces the current behavior exactly. Recursive handling — formerly a - fail-fast NotSupportedException placeholder — now assembles a genuinely runnable pipeline - path that the boundary-port resolver drives to order same-face hierarchy crossings, so the - pipeline no longer needs to reject the recursive mode. + fail-fast NotSupportedException placeholder — now assembles a genuinely runnable combined-pass + pipeline that the hierarchical algorithm drives in production (through RunRecursive) to lay + out a boundary-port container and its nested descendants together, so the pipeline no longer + needs to reject the recursive mode. tests: - LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing - LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline + - id: Rendering-Layout-LayeredPipeline-RecursiveCombinedPass + title: >- + The recursive-hierarchy stage sequence, assembled by AddRecursiveStages and run through + RunRecursive, shall lay out a boundary-port container and all of its nested descendants in one + combined pass — assigning layers and minimizing crossings recursively across every level, + seeding one crossing dummy per boundary face, and tagging each boundary-port crossing on its + augmented node — so the hierarchical algorithm can drive it in production to place and route + boundary-port hierarchies. + justification: | + Boundary-port hierarchies are laid out by running the recursive stage sequence over the + assembled merge region rather than by reconciling separately-placed scopes. Recursive layer + assignment and crossing minimization coordinate the ordering of the container faces that share + a boundary port, the merge-region graph assembler seeds one crossing dummy per boundary face + and flattens every interior node into its level, and the long-edge splitter carries each + crossing tag through untouched so a crossing dummy stays a tagged terminal hop. This combined + pass — not a post-hoc endpoint reconciliation — is the production path the hierarchical + algorithm uses whenever a scope contains at least one boundary port. + tests: + - CrossingMinimizer_MinimizeCrossingsRecursive_TwoLevelHierarchy_ChildOrderPropagatesToParent + - CrossingMinimizer_InteriorNodeReordering_OrdinaryNodeParticipatesInRealCrossingMinimization + - MergeRegionGraphAssembler_Assemble_SingleLevelBoundaryPort_ProducesOneChildLevel + - MergeRegionGraphAssembler_Assemble_ThreeLevelChain_RecursesToDepthThree + - MergeRegionGraphAssembler_Assemble_NonBoundaryInteriorNode_IncludedInFullFlattening + - LongEdgeSplitter_Apply_CrossingTaggedNode_PreservesTagAndIsNotSplit + - id: Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor title: >- An augmented node shall be able to carry an optional hierarchy-crossing descriptor @@ -83,21 +109,27 @@ sections: - id: Rendering-Layout-LayeredPipeline-BoundaryPortResolution title: >- - The boundary-port resolver shall reconcile each detected boundary port to one shared anchor - on its container's boundary carrying both labels, add one internal delegation connector per - internal edge into the container's placed interior, and order same-face crossings - deterministically. + The merge-region decomposer shall project the recursive combined-pass placement back into + per-scope geometry, resolving each detected boundary port to one shared anchor on its + container's face — the face given by BoundaryPortResolver.FaceForDirection — and taking every + converging edge's waypoints from the orthogonal corridor router so no endpoint is joined to + the anchor by a hand-built diagonal. justification: | - Rather than a literal single flattened cross-scope pass, the resolver enriches the external - anchor already produced by the per-scope leaf pass with the internal label and internal - delegation connectors, consolidating external and internal fan-out onto one physical - anchor. Ordering same-face crossings keeps multiple crossings on one face deterministic. + Rather than reconciling endpoints the per-scope leaf pass already placed, the decomposer reads + the combined pass's routed polylines directly: the external approach concatenates the approach + and container-link polylines, and the internal delegation prepends the shared anchor to the + delegation polyline with at most one orthogonal corner. Keying the shared anchor on the + boundary port's reference identity collapses the external and internal faces of one port onto + a single physical point, and FaceForDirection gives the anchor its container face for each of + the four flow directions. Routing every converging edge through the real router keeps the + fan-in, fan-out, and multi-level-chain approaches axis-aligned. tests: - - Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector - - Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity - - OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices - - OrderCrossings_NoTargets_ReturnsInputOrder - - OrderCrossings_Empty_ReturnsEmpty + - FaceForDirection_Right_ReturnsLeftFace + - FaceForDirection_Left_ReturnsRightFace + - FaceForDirection_Down_ReturnsTopFace + - FaceForDirection_Up_ReturnsBottomFace + - MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal + - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - id: Rendering-Layout-LayeredPipeline-Directions title: >- diff --git a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml index 4e5b6b4..90ff4c3 100644 --- a/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/hierarchical-layout-algorithm.yaml @@ -39,6 +39,7 @@ sections: tests: - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely - Apply_ThreeLevelNesting_Succeeds + - HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_GrowthCascadesThroughEveryEnclosingLevel - id: Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm title: >- @@ -63,6 +64,7 @@ sections: tests: - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely - Apply_CompoundGraph_DoesNotMutateInputNodeSizes + - HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange - id: Rendering-Layout-HierarchicalLayout-CrossContainerEdge title: >- @@ -126,23 +128,32 @@ sections: HierarchicalLayoutAlgorithm shall resolve a container's boundary (delegation) port — a port the container both exposes to an external approach edge and references from an edge inside its own child scope — to exactly one shared LayoutPort anchor on the container boundary - carrying both the external and internal labels, with every external approach and every - internal delegation edge reaching that one anchor, including under external and internal - fan-out and for two independent boundary ports on one container. + carrying both the external and internal labels, with every converging edge — every external + approach and every internal delegation edge — routed through the real orthogonal corridor + router onto that one anchor as an axis-aligned path with no direct diagonal, including under + external and internal fan-out, for two independent boundary ports on one container, and + through a multi-level delegation chain. justification: | A boundary/delegation port is the bulkhead-connector pattern: one physical anchor names both the connection reaching it from outside the container and the delegation relaying it into the - container's own children. Consolidating fan-out and reconciling both labels onto a single - anchor (rather than emitting a separate port per edge) keeps the rendered wiring unambiguous. - The resolution reconciles the anchor the per-scope leaf pass already produced with the - internal label and one delegation connector per internal edge, which satisfies the acceptance - criteria without a literal flattened cross-scope pass. + container's own children. The container and its children are laid out in one combined + recursive layered pass — assembled into a merge region, run through the recursive pipeline, + and projected back by the merge-region decomposer — so every converging edge is routed by the + same orthogonal corridor router that routes ordinary edges, rather than having an endpoint + patched onto the anchor with a hand-built diagonal. Consolidating fan-out and both labels onto + a single anchor (rather than emitting a separate port per edge) keeps the rendered wiring + unambiguous, and routing every converging edge orthogonally through the real router is what + keeps the external fan-in and the multi-level delegation chain free of the diagonal shortcut a + post-hoc endpoint reconciliation produced. tests: - Apply_BoundaryPortWithExternalAndInternalEdges_EmitsOneSharedAnchorCarryingBothLabels - Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor - Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors - Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance - Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance + - MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal + - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal + - HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath - id: Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows title: >- diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 4268912..640d285 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -35,8 +35,9 @@ rejected. No stage is mocked — real `LayeredGraph` instances flow through ever A verification run passes when every named scenario below asserts without unexpected exception, and the referenced tests cover each `Rendering-Layout-LayeredPipeline-*` requirement. Any drift in per-stage geometry, in byte-identity with the legacy oracle on the random and named topologies, -in supported hierarchy handling (recursive input must assemble a runnable pipeline; boundary-port -detection and reconciliation must behave as specified), in flow directions, in orthogonal +in supported hierarchy handling (recursive input must assemble a runnable pipeline; the recursive +combined pass, boundary-port detection, and the decomposer's boundary-port resolution must behave as +specified), in flow directions, in orthogonal waypoint shape, in back-edge approach behavior, in component packing determinism, in shared `LayeredGraph` state validation, or in `LayeredLayoutPipeline` input validation constitutes a failure. @@ -57,6 +58,19 @@ failure. hierarchy handling now assembles a runnable pipeline rather than throwing, alongside the flat default sequence exercised by `LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing`. +- **Recursive combined pass** (`Rendering-Layout-LayeredPipeline-RecursiveCombinedPass`): + `CrossingMinimizer_MinimizeCrossingsRecursive_TwoLevelHierarchy_ChildOrderPropagatesToParent` and + `CrossingMinimizer_InteriorNodeReordering_OrdinaryNodeParticipatesInRealCrossingMinimization` confirm + the recursive crossing minimizer coordinates the ordering of a container's boundary faces with its + children's interior across levels (a resolved child order propagates to the parent, and an ordinary + interior node genuinely reorders under outer-scope pressure). + `MergeRegionGraphAssembler_Assemble_SingleLevelBoundaryPort_ProducesOneChildLevel`, + `MergeRegionGraphAssembler_Assemble_ThreeLevelChain_RecursesToDepthThree`, and + `MergeRegionGraphAssembler_Assemble_NonBoundaryInteriorNode_IncludedInFullFlattening` confirm the + assembler builds one child level per container, recurses to arbitrary depth, and flattens every + interior node into its level. `LongEdgeSplitter_Apply_CrossingTaggedNode_PreservesTagAndIsNotSplit` + confirms a seeded boundary-crossing tag survives the augmented-node rebuild and its dummy is never + split. - **Hierarchy-crossing descriptor** (`Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor`): `LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline` exercises the recursive path that consumes the optional `AugNode` hierarchy-crossing descriptor. @@ -67,15 +81,13 @@ failure. `HierarchyMergeRegionBuilder` detects a container's boundary ports transitively and to unbounded depth while excluding same-scope and leaf-node ports. - **Boundary-port resolution** (`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`): - `Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector` confirms `BoundaryPortResolver` - enriches the leaf-pass anchor with the internal label and adds the internal delegation connector, - `Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity` confirms - the leaf-anchor and nested-target matching is keyed on `LayoutPort.SourcePort` reference identity — - not on the optional, frequently-null `ExternalLabel` string — so two independent boundary ports on one - container that both leave `ExternalLabel` null resolve to their own true external connector, never - each other's, and `OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices`, - `OrderCrossings_NoTargets_ReturnsInputOrder`, and `OrderCrossings_Empty_ReturnsEmpty` confirm - deterministic same-face crossing ordering. + `FaceForDirection_Right_ReturnsLeftFace`, `FaceForDirection_Left_ReturnsRightFace`, + `FaceForDirection_Down_ReturnsTopFace`, and `FaceForDirection_Up_ReturnsBottomFace` confirm the one + retained `BoundaryPortResolver` helper maps each flow direction to the container face the shared + anchor sits on, and `MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal` + and `MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal` confirm + the decomposer projects the combined-pass placement back so every converging edge is routed + orthogonally onto the shared anchor with no direct diagonal. - **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, @@ -176,6 +188,13 @@ failure. - **`Rendering-Layout-LayeredPipeline-FlatHierarchyOnly`**: LayeredLayoutPipeline_RunDefaultStages_ChainGraph_PopulatesWaypointsWithoutThrowing, LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline +- **`Rendering-Layout-LayeredPipeline-RecursiveCombinedPass`**: + CrossingMinimizer_MinimizeCrossingsRecursive_TwoLevelHierarchy_ChildOrderPropagatesToParent, + CrossingMinimizer_InteriorNodeReordering_OrdinaryNodeParticipatesInRealCrossingMinimization, + MergeRegionGraphAssembler_Assemble_SingleLevelBoundaryPort_ProducesOneChildLevel, + MergeRegionGraphAssembler_Assemble_ThreeLevelChain_RecursesToDepthThree, + MergeRegionGraphAssembler_Assemble_NonBoundaryInteriorNode_IncludedInFullFlattening, + LongEdgeSplitter_Apply_CrossingTaggedNode_PreservesTagAndIsNotSplit - **`Rendering-Layout-LayeredPipeline-HierarchyCrossingDescriptor`**: LayeredLayoutPipeline_Build_RecursiveHierarchy_ProducesRunnablePipeline - **`Rendering-Layout-LayeredPipeline-BoundaryPortDetection`**: @@ -183,10 +202,10 @@ failure. Collect_DelegationPort_DetectedWithExternalAndInternalEdges, Collect_TwoIndependentPorts_DetectsBoth, CollectRecursive_ThreeLevelChain_ReportsEveryLevel, Collect_PortOnLeafNode_NotDetected - **`Rendering-Layout-LayeredPipeline-BoundaryPortResolution`**: - Resolve_LeafAnchoredPort_EnrichesAnchorAndAddsInternalConnector, - Resolve_TwoBoundaryPortsWithSharedNullExternalLabel_ResolveIndependentlyByReferenceIdentity, - OrderCrossings_MultipleCrossings_ReturnsPermutationOfIndices, OrderCrossings_NoTargets_ReturnsInputOrder, - OrderCrossings_Empty_ReturnsEmpty + FaceForDirection_Right_ReturnsLeftFace, FaceForDirection_Left_ReturnsRightFace, + FaceForDirection_Down_ReturnsTopFace, FaceForDirection_Up_ReturnsBottomFace, + MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, + MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - **`Rendering-Layout-LayeredPipeline-Directions`**: AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged, AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces, diff --git a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md index 60e874a..24b7cb7 100644 --- a/docs/verification/rendering-layout/hierarchical-layout-algorithm.md +++ b/docs/verification/rendering-layout/hierarchical-layout-algorithm.md @@ -32,7 +32,8 @@ the referenced tests cover each `Rendering-Layout-HierarchicalLayout-*` requirem the stable identifier (`"hierarchical"`), in the flat-graph byte-equivalence guarantee, in container sizing (padding, title band), in per-scope algorithm resolution, in cross-container LCA edge routing, in same-scope named-port edge routing (or its ancestor-coordinate translation), in -the boundary (delegation) port reconciliation behavior, in the narrowed boundary-crossing port +the boundary (delegation) port combined-pass routing behavior (one shared anchor with every converging +edge routed orthogonally), in the narrowed boundary-crossing port `NotSupportedException` behavior, or in the argument-null validation behavior constitutes a failure. Mutation of input node sizes also constitutes a failure. @@ -50,7 +51,9 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a `Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely` confirms a container is sized to enclose its children and each nested box lies within the container's bounds; `Apply_ThreeLevelNesting_Succeeds` confirms three levels compose so a box contains a box that contains - a box. + a box. `HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_GrowthCascadesThroughEveryEnclosingLevel` + confirms the two-pass cascading sizing: when the innermost container must grow, the growth cascades + outward through every enclosing level's own sizing pass so no ancestor clips its now-larger descendant. - **Per-node algorithm** (`Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm`): `Apply_ContainmentRootWithLayeredContainer_Composes` and `Apply_LayeredRootWithContainmentContainer_Composes` confirm a container overriding its algorithm is @@ -60,6 +63,9 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a their own coordinate space and composed into the parent at absolute coordinates. `Apply_CompoundGraph_DoesNotMutateInputNodeSizes` confirms the engine sizes containers over an internal sized view and never mutates the caller's input node dimensions. + `HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange` + confirms the combined-pass gate is additive: a two-container, cross-container-edge hierarchy with no + boundary port is composed byte-for-byte identically to before the boundary-port combined pass existed. - **Cross-container edge** (`Rendering-Layout-HierarchicalLayout-CrossContainerEdge`): `Apply_CrossContainerEdge_RoutesAroundInterveningContainer` confirms an edge between children of different sibling containers is routed at the owning scope and no routed segment passes through the @@ -104,8 +110,17 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a `Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance` go beyond anchor count and label pairing to assert connector *provenance*: each anchor's external connector is traced back to its own true external sibling and its internal connector to its own true child, with no - cross-wiring, in the case (a shared or both-null `ExternalLabel`) that previously caused the resolver's - label-string matching to silently mis-reconcile the two ports' anchors. + cross-wiring, in the case (a shared or both-null `ExternalLabel`) that previously caused label-string + matching to silently mis-associate the two ports' anchors. + Full-waypoint-shape regression is asserted by + `MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal` and + `MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal`, which + inspect every segment of every converging edge and fail on any direct diagonal — the exact + full-waypoint check the earlier reconciliation approach lacked, both of which fail on that old code + and pass on the combined pass. `HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath` + extends this to a three-level delegation chain, confirming the whole chain reads as one connected, + strictly orthogonal path from the outermost sibling down to the innermost leaf across both boundary + crossings. - **Boundary port edge throws** (`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`): `Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws` confirms a port owned by a plain (non-container) node with an edge straight into a different container's nested child — a @@ -125,11 +140,13 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a - **`Rendering-Layout-HierarchicalLayout-FlatEquivalence`**: Apply_FlatRandomGraphs_MatchLayeredAlgorithmExactly, Apply_FlatRandomGraphs_MatchContainmentAlgorithmExactly - **`Rendering-Layout-HierarchicalLayout-NestsChildren`**: - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_ThreeLevelNesting_Succeeds + Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_ThreeLevelNesting_Succeeds, + HierarchicalLayoutAlgorithm_ThreeLevelChain_InnermostContainerGrows_GrowthCascadesThroughEveryEnclosingLevel - **`Rendering-Layout-HierarchicalLayout-PerNodeAlgorithm`**: Apply_ContainmentRootWithLayeredContainer_Composes, Apply_LayeredRootWithContainmentContainer_Composes - **`Rendering-Layout-HierarchicalLayout-HierarchyHandling`**: - Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_CompoundGraph_DoesNotMutateInputNodeSizes + Apply_TwoLevelNesting_SizesContainerAndNestsChildrenAbsolutely, Apply_CompoundGraph_DoesNotMutateInputNodeSizes, + HierarchicalLayoutAlgorithm_NoBoundaryPortHierarchy_OutputIsByteIdenticalBeforeAndAfterChange - **`Rendering-Layout-HierarchicalLayout-CrossContainerEdge`**: Apply_CrossContainerEdge_RoutesAroundInterveningContainer - **`Rendering-Layout-HierarchicalLayout-CascadesOptions`**: @@ -147,7 +164,10 @@ behavior constitutes a failure. Mutation of input node sizes also constitutes a Apply_BoundaryPortFanOut_ResolvesToOneSharedAnchor, Apply_TwoIndependentBoundaryPortsOnOneContainer_EmitsTwoAnchors, Apply_TwoIndependentBoundaryPortsWithSharedNullExternalLabel_PreservesConnectorProvenance, - Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance + Apply_TwoIndependentBoundaryPortsWithIdenticalExternalLabel_PreservesConnectorProvenance, + MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, + MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, + HierarchicalLayoutAlgorithm_ThreeLevelDelegationChain_EndToEnd_ProducesConnectedOrthogonalPath - **`Rendering-Layout-HierarchicalLayout-BoundaryPortEdgeThrows`**: Apply_PortOnNonContainerCrossingIntoDifferentContainer_Throws - **`Rendering-Layout-HierarchicalLayout-ValidatesGraph`**: diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index f7a5909..aa12b49 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -53,6 +53,7 @@ internal static class GalleryCatalog public const string PortsShowcaseVerticalSvg = "ports-showcase-vertical.svg"; public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; + public const string BoundaryPortsShowcaseDeepChainSvg = "boundary-ports-showcase-deep-chain.svg"; /// Gets the browsable sections of the gallery, in display order. public static IReadOnlyList Sections { get; } = @@ -222,9 +223,10 @@ internal static class GalleryCatalog + "a named port carrying BOTH an external and an internal label at one shared physical " + "anchor on its boundary. An external approach edge from a sibling reaches the anchor from " + "outside, while one or more internal delegation edges relay the connection inward to the " - + "container's nested children. The external label reads outward (away from the container) " - + "and the internal label reads inward, and the anchor consolidates external and internal " - + "fan-out onto a single boundary point.", + + "container's nested children. The container and its children are laid out in one combined " + + "recursive pass, and every converging edge — external approach and internal delegation " + + "alike — is routed through the orthogonal corridor router onto that single shared anchor, " + + "with the external label reading outward and the internal label reading inward.", [ new GalleryImage( BoundaryPortsShowcaseHorizontalSvg, @@ -233,13 +235,22 @@ internal static class GalleryCatalog + "both a 'command' external label (reading outward) and a 'dispatch' internal " + "label (reading inward) at the same shared anchor. The external approach edge from " + "the sibling and both internal delegation edges (internal fan-out to two nested " - + "children) reach that one anchor."), + + "children) are routed orthogonally onto that one anchor."), new GalleryImage( BoundaryPortsShowcaseVerticalSvg, "Two siblings joined to a container's top-face boundary port and one child", "The companion downward-flowing case: the boundary port anchors on the container's " - + "top face, with external fan-out (two sibling approach edges) consolidated onto " - + "the one shared anchor, which then delegates inward to the single nested child."), + + "top face, with external fan-out (two sibling approach edges) both routed " + + "orthogonally onto the one shared anchor, which then delegates inward to the " + + "single nested child."), + new GalleryImage( + BoundaryPortsShowcaseDeepChainSvg, + "A boundary port delegating through a nested boundary port to a leaf", + "A three-level delegation chain: a sibling approaches an outer container's boundary " + + "port, which delegates inward to a nested container's own boundary port, which " + + "delegates again to the innermost leaf. Both boundary crossings carry an outward " + + "external and an inward internal label, and the whole chain is routed orthogonally " + + "in one combined recursive pass with no diagonal shortcut at either boundary."), ]), ]; } diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 820595e..d473cb0 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -490,11 +490,13 @@ public static LayoutGraph PortsShowcaseVertical() /// and an /// and is referenced by an edge inside its owning container's child scope (the inward /// delegation edge is the structural signal that marks it as a boundary port). The hierarchical - /// engine reconciles the external approach edge and every internal delegation edge onto one - /// shared physical anchor on the container boundary, carrying both labels: the external label - /// reads outward (away from the container) and the internal label reads inward (into the - /// container's interior). This diagram also exercises internal fan-out: the single port - /// delegates to two distinct nested children, both connectors reaching the one shared anchor. + /// engine lays the container and its children out in one combined recursive pass and routes the + /// external approach edge and every internal delegation edge through its orthogonal corridor + /// router onto one shared physical anchor on the container boundary, carrying both labels: the + /// external label reads outward (away from the container) and the internal label reads inward + /// (into the container's interior). This diagram also exercises internal fan-out: the + /// single port delegates to two distinct nested children, both connectors routed onto the one + /// shared anchor. /// /// A compound graph whose container exposes one left-face boundary port with internal fan-out. public static LayoutGraph BoundaryPortsShowcaseHorizontal() @@ -561,6 +563,65 @@ public static LayoutGraph BoundaryPortsShowcaseVertical() return graph; } + /// + /// A three-level boundary-port delegation chain: a sibling approaches an outer + /// container's boundary port, which delegates inward to a nested container's own boundary port, + /// which in turn delegates inward again to a leaf child at the innermost level — two boundary + /// crossing points stacked in one recursive descent. + /// + /// + /// This exercises the recursive hierarchical engine at depth three. Each of the two boundary + /// ports (the outer system container's and the nested subsystem container's) + /// carries both a reading outward and a + /// reading inward, resolving to one shared physical + /// anchor on its own container boundary. The single external approach and every delegation edge + /// — across both boundary crossings — are routed through the orthogonal corridor router in the + /// one combined pass, so the whole chain reads as an unbroken orthogonal path from the outermost + /// sibling down to the innermost leaf with no diagonal shortcut at either boundary. + /// + /// A compound graph whose boundary port delegates through a nested container's own boundary port to a leaf. + public static LayoutGraph BoundaryPortsShowcaseDeepChain() + { + var graph = new LayoutGraph(); + + var source = AddLabelled(graph, "source", "Source"); + + // Outer container; the engine grows it to fit its nested subsystem. + var system = graph.AddNode("system", 10, 10); + system.Label = "System"; + + // The outer boundary port: external label reads outward, internal label reads inward, at one + // shared anchor on the System boundary. + var request = system.Ports.AddPort("request"); + request.ExternalLabel = "request"; + request.InternalLabel = "route"; + + // Nested container inside System; it too exposes its own boundary port. + var subsystem = system.Children.AddNode("subsystem", 10, 10); + subsystem.Label = "Subsystem"; + + // The inner boundary port: its external face is approached by System's own delegation edge, and + // its internal face delegates inward again to the innermost leaf. + var relay = subsystem.Ports.AddPort("relay"); + relay.ExternalLabel = "relay"; + relay.InternalLabel = "handle"; + + var core = AddLabelled(subsystem.Children, "core", "Core"); + + // The external approach edge lives in the root scope, joining the sibling to the outer port. + Connect(graph, "source-request", source, request, null); + + // The first delegation edge lives inside System's own child scope, relaying the outer boundary + // port inward to the nested container's boundary port (crossing point one). + Connect(system.Children, "request-relay", request, relay, null); + + // The second delegation edge lives inside Subsystem's own child scope, relaying the inner + // boundary port inward to the innermost leaf child (crossing point two). + Connect(subsystem.Children, "relay-core", relay, core, null); + + return graph; + } + /// Computes the height of a titled compartment (a title row plus one row per line, plus the /// leading title-area gap and the trailing bottom gap), matching the renderer's own layout /// formula so the compartment content never overflows the box. diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index 494ea81..e36dbc8 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -324,8 +324,8 @@ public void Gallery_BoundaryPortsShowcaseHorizontal_RendersSvg() /// /// Renders the vertical boundary-ports showcase to SVG — the companion to /// — proving a downward-flowing - /// container's top-face boundary port consolidates external fan-out (two sibling approach edges) - /// onto one shared anchor that then delegates inward to the nested child. + /// container's top-face boundary port routes external fan-out (two sibling approach edges) + /// orthogonally onto one shared anchor that then delegates inward to the nested child. /// [Fact] public void Gallery_BoundaryPortsShowcaseVertical_RendersSvg() @@ -335,4 +335,19 @@ public void Gallery_BoundaryPortsShowcaseVertical_RendersSvg() GalleryDiagrams.BoundaryPortsShowcaseVertical(), Themes.Dark); } + + /// + /// Renders the three-level boundary-ports delegation chain to SVG — proving an outer container's + /// boundary port delegates inward to a nested container's own boundary port, which delegates + /// again to the innermost leaf, so the whole chain routes as one unbroken orthogonal path + /// through the recursive engine's single combined pass across both boundary crossings. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseDeepChain_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.BoundaryPortsShowcaseDeepChainSvg, + GalleryDiagrams.BoundaryPortsShowcaseDeepChain(), + Themes.Dark); + } } From f32831c59d770e4ae9a96f7f557b7231dab17377 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 11:12:58 -0400 Subject: [PATCH 15/23] style(layout): reword terms for spell-check compliance --- .../HierarchicalLayoutAlgorithm.cs | 2 +- .../HierarchicalLayoutAlgorithmTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs index a797596..f6bfb49 100644 --- a/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/HierarchicalLayoutAlgorithm.cs @@ -118,7 +118,7 @@ public sealed class HierarchicalLayoutAlgorithm : ILayoutAlgorithm /// /// Maximum number of cascading-sizing re-runs the combined pass performs before accepting the /// current sizes. Growth only ever propagates outward through the finite nesting chain, so a stable - /// fixpoint is reached in at most chain-depth iterations; this cap is a safety bound well above any + /// fixed point is reached in at most chain-depth iterations; this cap is a safety bound well above any /// realistic nesting depth. /// private const int MaxSizingIterations = 32; diff --git a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs index 22b4228..941fbbc 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/HierarchicalLayoutAlgorithmTests.cs @@ -1484,7 +1484,7 @@ private static LayoutGraph BuildNoBoundaryPortHierarchy() // A genuine cross-container edge between the two nested children (routed by the cross-container // router, not a boundary port), plus an ordinary edge between the two container nodes so the // scope has real layered structure that the combined pass would place differently. - graph.AddEdge("leftchild-rightchild", leftChild, rightChild); + graph.AddEdge("leftChild-rightChild", leftChild, rightChild); graph.AddEdge("left-right", left, right); return graph; From a565213d32a95c159f34dca453db68c573412de5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 13:18:51 -0400 Subject: [PATCH 16/23] fix(svg): skip spurious rounded-corner arc at collinear waypoints BuildLinePath emitted a shortened-line+arc pair unconditionally for every interior waypoint, even when the incoming and outgoing directions were parallel and same-sense (not a real corner). Hoist the inline 0.001 tolerance literal into a named DirectionTolerance constant and add a collinearity check (normalized cross/dot product) before the existing degenerate-length check, so a collinear waypoint continues straight through with a plain L command instead of a spurious rounded bump. Add a regression test asserting no arc command is emitted for a collinear interior waypoint, and document the collinear-waypoint skip in the SvgRenderer unit design/verification docs and reqstream test list. --- .cspell.yaml | 3 ++ docs/design/rendering-svg/svg-renderer.md | 15 ++++++--- .../reqstream/rendering-svg/svg-renderer.yaml | 1 + .../rendering-svg/svg-renderer.md | 7 +++-- .../SvgRenderer.cs | 26 +++++++++++++++- .../SvgRendererPortedTests.cs | 31 +++++++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.cspell.yaml b/.cspell.yaml index 31aacb0..5e78f6a 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -68,6 +68,9 @@ words: - abcdeabcde - settability - retarget + - collinear + - collinearity + - collinearly # Non-ASCII terms retained verbatim in extracted source comments - façade - Köpf diff --git a/docs/design/rendering-svg/svg-renderer.md b/docs/design/rendering-svg/svg-renderer.md index 884d8b7..4128786 100644 --- a/docs/design/rendering-svg/svg-renderer.md +++ b/docs/design/rendering-svg/svg-renderer.md @@ -167,11 +167,16 @@ labels are drawn in the final pass of `Render` so later wires cannot cover earli **`BuildLinePath(...)`** Builds the SVG path `d` string. When `cornerRadius` is zero, emits plain `M` / `L` commands. When -positive, each interior waypoint is replaced with a shortened `L` command to the arc start point, -followed by an `A` (elliptical arc) command whose sweep direction is determined from the cross product -of the incoming and outgoing unit direction vectors. The radius is clamped to half the shorter -adjacent segment to prevent overshoot. At the first and last bends the radius is additionally clamped -so the rounded corner completes at least the marker's along-line length +positive, each interior waypoint is first checked for collinearity: if the incoming (previous→current) +and outgoing (current→next) direction vectors are parallel and same-sense (a normalized cross product +within `DirectionTolerance` of zero and a positive dot product — not a reversal), the waypoint sits on +a straight run rather than a genuine corner, so it is emitted as a plain `L` command and no arc is +drawn there; rounding a non-corner waypoint would otherwise draw a spurious bump in an already-straight +run. Any waypoint that fails this collinearity check is replaced with a shortened `L` command to the +arc start point, followed by an `A` (elliptical arc) command whose sweep direction is determined from +the cross product of the incoming and outgoing unit direction vectors. The radius is clamped to half +the shorter adjacent segment to prevent overshoot. At the first and last bends the radius is +additionally clamped so the rounded corner completes at least the marker's along-line length (`NotationMetrics.AlongLineLength` for the `SourceEnd` / `TargetEnd` styles) before the endpoint, so the curve never intrudes into the end-marker zone. diff --git a/docs/reqstream/rendering-svg/svg-renderer.yaml b/docs/reqstream/rendering-svg/svg-renderer.yaml index 5d0c0d8..2203632 100644 --- a/docs/reqstream/rendering-svg/svg-renderer.yaml +++ b/docs/reqstream/rendering-svg/svg-renderer.yaml @@ -124,6 +124,7 @@ sections: Rounded connector corners convey the theme's configured line-corner radius. tests: - SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath + - SvgRenderer_Render_SingleLine_CollinearInteriorWaypoint_ProducesNoArcInPath - id: Rendering-Svg-SvgRenderer-RenderLineDashed title: >- diff --git a/docs/verification/rendering-svg/svg-renderer.md b/docs/verification/rendering-svg/svg-renderer.md index 09431a9..39575fc 100644 --- a/docs/verification/rendering-svg/svg-renderer.md +++ b/docs/verification/rendering-svg/svg-renderer.md @@ -95,9 +95,11 @@ well-formed XML. Tests `SvgRenderer_Render_SingleLine_ProducesPathElement`, `SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath`, +`SvgRenderer_Render_SingleLine_CollinearInteriorWaypoint_ProducesNoArcInPath`, `SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray`, and `SvgRenderer_Render_LineWithMidpointLabel_ProducesTextElement` render connector lines and assert on -``, an arc command, `stroke-dasharray`, and the connector label text. +``, an arc command, the absence of an arc command at a collinear (non-corner) interior waypoint, +`stroke-dasharray`, and the connector label text. The midpoint-label scenario asserts that a `` element containing the label appears in the final SVG. The design places connector labels in a final pass using `ConnectorLabelPlacer`; this scenario @@ -250,7 +252,8 @@ marker id appears somewhere in the document. This prevents a false pass if the m `SvgRenderer_Render_LabelWithXmlSpecialCharacters_ProducesWellFormedEscapedSvg` - **`Rendering-Svg-SvgRenderer-RenderLine`**: `SvgRenderer_Render_SingleLine_ProducesPathElement` - **`Rendering-Svg-SvgRenderer-RenderLineRoundedCorners`**: - `SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath` + `SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath`, + `SvgRenderer_Render_SingleLine_CollinearInteriorWaypoint_ProducesNoArcInPath` - **`Rendering-Svg-SvgRenderer-RenderLineDashed`**: `SvgRenderer_Render_SingleLine_Dashed_ProducesDashArray` - **`Rendering-Svg-SvgRenderer-RenderLineMidpointLabel`**: diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index b4b8a27..21a8cb7 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -806,6 +806,11 @@ private static string BuildLinePath( return sb.ToString(); } + // Shared tolerance for both the direction-collinearity check and the degenerate + // zero-length-segment check below - a single named constant avoids a second magic + // number drifting out of sync with the first. + const double DirectionTolerance = 0.001; + // Arc-at-bends: replace each interior waypoint with a shortened L + arc A command for (var i = 1; i < waypoints.Count; i++) { @@ -832,7 +837,26 @@ private static string BuildLinePath( var outDy = next.Y - cur.Y; var outLen = Math.Sqrt(outDx * outDx + outDy * outDy); - if (inLen < 0.001 || outLen < 0.001) + // Collinearity check: if the incoming and outgoing directions are parallel and + // same-sense (normalized cross product ~0 and dot product positive, i.e. not a + // reversal), this waypoint sits on a straight run rather than a real corner. Skip + // the arc-rounding entirely so the line continues straight through it - rounding a + // waypoint that is not an actual turn would draw a spurious bump in the path. Both + // lengths are checked against DirectionTolerance first so this never divides by a + // near-zero length; genuinely degenerate segments fall through to the next check. + if (inLen >= DirectionTolerance && outLen >= DirectionTolerance) + { + var crossNorm = (inDx * outDy - inDy * outDx) / (inLen * outLen); + var dotNorm = (inDx * outDx + inDy * outDy) / (inLen * outLen); + if (Math.Abs(crossNorm) <= DirectionTolerance && dotNorm > 0) + { + // Collinear waypoint: no corner to round, continue straight through it + sb.Append(CultureInfo.InvariantCulture, $" L {F(cur.X * scale)} {F(cur.Y * scale)}"); + continue; + } + } + + if (inLen < DirectionTolerance || outLen < DirectionTolerance) { // Degenerate segment: fall back to a plain line command sb.Append(CultureInfo.InvariantCulture, $" L {F(cur.X * scale)} {F(cur.Y * scale)}"); diff --git a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs index 5a98b62..4199512 100644 --- a/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs +++ b/test/DemaConsulting.Rendering.Svg.Tests/SvgRendererPortedTests.cs @@ -142,6 +142,37 @@ public void SvgRenderer_Render_SingleLine_WithCornerRadius_ProducesArcInPath() Assert.Contains(" A ", svgText, StringComparison.Ordinal); } + /// + /// Render a LayoutLine with 3 waypoints and a positive LineCornerRadius theme, where the + /// interior waypoint is collinear with its neighbors (the incoming and outgoing directions are + /// parallel and same-sense, not a real turn), produces SVG output containing NO arc command + /// (" A ") in the path data, confirming that a collinear waypoint is skipped rather than rounded + /// into a spurious bump. + /// + [Fact] + public void SvgRenderer_Render_SingleLine_CollinearInteriorWaypoint_ProducesNoArcInPath() + { + // Arrange: a line whose interior waypoint sits on the same straight run as its neighbors + var renderer = new SvgRenderer(); + var line = new LayoutLine( + [new Point2D(10, 50), new Point2D(50, 50), new Point2D(90, 50)], + EndMarkerStyle.None, + EndMarkerStyle.None, + LineStyle.Solid, + null); + var layout = new LayoutTree(200, 100, [line]); + var options = new RenderOptions(Themes.Light); // LineCornerRadius = 4.0 + using var output = new MemoryStream(); + + // Act + renderer.Render(layout, options, output); + + // Assert: no arc command is present in path data for the collinear waypoint + output.Position = 0; + var svgText = ReadAllText(output); + Assert.DoesNotContain(" A ", svgText, StringComparison.Ordinal); + } + /// /// Render a dashed LayoutLine produces SVG output containing the stroke-dasharray /// attribute, confirming that dashed line style is mapped to SVG dash patterns. From 97db6db77bf189fc6beb390d3a4d0b4afb2b6b05 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 13:19:02 -0400 Subject: [PATCH 17/23] fix(layout): pin hierarchy-crossing dummy to parent-resolved anchor An Internal-face hierarchy-crossing dummy (the shared fan-out point a boundary port's delegation edges route through) was centered by BrandesKopfPlacer purely among its own level's interior fan-out targets, ignoring the parent scope's already-resolved anchor position for the same boundary port. This produced an unnecessary extra back-and-forth detour for whichever delegation target sat nearer the anchor. Add an optional PinnedCrossAxis field to AugNode (defaulting to null, a byte-identical no-op for every non-boundary-port node and the flat pipeline). MergeRegionGraphAssembler.BuildExternalCrossingIndex and PinIncomingCrossings compute the parent level's already-placed cross-axis offset (relative to the port's own container node, since each level has its own local coordinate origin) and seed it onto the matching Internal-face crossing dummy once the parent level has been placed - guaranteed by LayeredLayoutPipeline.RunRecursive's existing parent-before-child level iteration order (a consequence of BuildAllLevelGraphs' pre-order population and Dictionary's insertion-order preservation). BrandesKopfPlacer.AssignCoordinatesAug then overrides just that node's cross-axis coordinate with the pinned value after ordinary alignment/compaction. Add a regression test reproducing the dispatch to Driver/Logger scenario, asserting both delegation connectors take a minimal-bend path with no direction reversal. --- .../Engine/Layered/BrandesKopfPlacer.cs | 15 ++ .../Engine/Layered/LayeredGraph.cs | 13 +- .../Engine/Layered/LayeredLayoutPipeline.cs | 13 +- .../Layered/MergeRegionGraphAssembler.cs | 128 ++++++++++++++++++ .../Layered/MergeRegionDecomposerTests.cs | 95 +++++++++++++ 5 files changed, 262 insertions(+), 2 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs index e9b7e99..83ec84d 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/BrandesKopfPlacer.cs @@ -95,6 +95,21 @@ private static (double[] AugX, double[] AugY, double[] ColumnX, double[] MaxColW // Assign Y coordinates using the Brandes-Köpf balanced four-layout algorithm. var augY = BkAssignYCoordinates(augNodes, groups, augEdges, nodeSpacing); + // Honor any pinned cross-axis coordinate: a hierarchy-crossing dummy tagged with + // AugNode.PinnedCrossAxis (set by MergeRegionGraphAssembler.PinIncomingCrossings for the + // recursive pipeline) must anchor to its parent scope's already-resolved boundary-port position + // rather than the ordinary fork-centering value the alignment/compaction above just computed for + // it. This bypasses ordinary placement for that node only; every other node's value is untouched. + // AugNode.PinnedCrossAxis defaults to null and is never set by the flat (non-hierarchical) + // pipeline, so this loop is a no-op there. + for (var i = 0; i < numAug; i++) + { + if (augNodes[i].PinnedCrossAxis is { } pinnedCrossAxis) + { + augY[i] = pinnedCrossAxis; + } + } + return (augX, augY, columnX, maxColWidth); } diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs index 4ee0825..1faa3ec 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredGraph.cs @@ -57,12 +57,23 @@ internal enum HierarchyCrossingFace /// real node and every long-edge dummy on that path, keeping default construction and every existing /// flat caller byte-identical. /// +/// +/// When non-, pins this node's cross-axis coordinate (Y for horizontal flow, X +/// for vertical flow) to an already-resolved value from an enclosing scope, rather than letting +/// BrandesKopfPlacer derive it from ordinary fork-centering/alignment. MergeRegionGraphAssembler +/// sets this on a child level's dummy to the parent scope's resolved +/// boundary-port anchor, so the child's fan-out does not re-center independently of where the parent +/// already placed the port. Like , this is by default for +/// every real node, every long-edge dummy, and every node produced by the flat (non-hierarchical) +/// pipeline, keeping default construction and every existing flat caller byte-identical. +/// internal sealed record AugNode( double Width, double Height, int Layer, bool IsDummy = false, - HierarchyCrossing? Crossing = null); + HierarchyCrossing? Crossing = null, + double? PinnedCrossAxis = null); /// A sub-edge in the augmented graph after long-edge splitting. /// Index of the source augmented node. diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs index e61dce5..a8378f9 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredLayoutPipeline.cs @@ -103,9 +103,20 @@ public RecursiveLayoutResult RunRecursive(AssembledMergeRegion region) // Stage 4: hierarchy-aware crossing minimization with boundary-order propagation. new CrossingMinimizer().MinimizeCrossingsRecursive(region.Root, levels); + // Lookup from each boundary port to its external-face (outgoing) crossing's owning level and + // node index, so a descendant level's internal-face (incoming) crossing dummy can be pinned to + // the parent's already-resolved anchor once that parent level has been placed below. + var externalCrossingIndex = MergeRegionGraphAssembler.BuildExternalCrossingIndex(levels); + // Stages 5-9: place, distribute ports, route, join long edges, and transform axes, per level. - foreach (var graph in levels.Values.Select(levelGraph => levelGraph.Graph)) + // Iterating levels.Values visits a parent level before its children (BuildAllLevelGraphs + // populates in pre-order and Dictionary preserves insertion order), so by the time a child + // level's PinIncomingCrossings call runs, every parent it could reference has already completed + // every stage below, including AxisTransform. + foreach (var levelGraph in levels.Values) { + var graph = levelGraph.Graph; + MergeRegionGraphAssembler.PinIncomingCrossings(levelGraph, externalCrossingIndex, levels, Direction); new BrandesKopfPlacer().Apply(graph); new PortDistributor().Apply(graph); new LayeredCorridorRouter().Apply(graph); diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs index 516c03b..cf59760 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/MergeRegionGraphAssembler.cs @@ -257,6 +257,13 @@ public static IReadOnlyDictionary BuildAllL /// The level to build a graph for. /// The flow direction each level's graph is laid out along. /// The accumulating level-to-graph lookup. + /// + /// Populates in pre-order (a level before its children) and + /// reliably preserves insertion order absent removals, so + /// LayeredLayoutPipeline.RunRecursive's later iteration over this dictionary's values always + /// visits a parent level before its children. depends on this + /// ordering guarantee to read a parent level's already-placed coordinates while processing a child. + /// private static void BuildAllLevelGraphs( MergeRegionLevel level, LayoutDirection direction, @@ -269,6 +276,127 @@ private static void BuildAllLevelGraphs( } } + /// + /// Builds a lookup from each boundary port to the owning level and augmented-node index of its + /// external-face (outgoing) crossing dummy, so a descendant level's internal-face (incoming) crossing + /// dummy for the same port can find the corresponding parent-scope placement once that parent level + /// has been placed. + /// + /// Every nesting level's assembled graph, as built by . + /// A lookup from boundary port to the level owning its external-face crossing and that crossing's node index. + /// Thrown when is . + public static IReadOnlyDictionary BuildExternalCrossingIndex( + IReadOnlyDictionary levels) + { + ArgumentNullException.ThrowIfNull(levels); + + var result = new Dictionary(); + foreach (var (level, levelGraph) in levels) + { + foreach (var crossing in levelGraph.Crossings) + { + if (crossing.Face == HierarchyCrossingFace.External) + { + result[crossing.Boundary.Port] = (level, crossing.NodeIndex); + } + } + } + + return result; + } + + /// + /// Pins a level's internal-face (incoming) crossing dummies to the parent scope's already-resolved + /// cross-axis coordinate for the matching external-face (outgoing) crossing, so + /// BrandesKopfPlacer anchors each dummy to where the parent already placed the boundary port + /// instead of independently re-centering it among this level's own interior fan-out targets. + /// + /// + /// Must run after the parent level's own placement stages have completed - guaranteed by + /// LayeredLayoutPipeline.RunRecursive's parent-before-child level iteration order, itself a + /// consequence of 's pre-order + /// population (see that method's remarks) - and before this level's own BrandesKopfPlacer runs. + /// The pin is read from whichever abstract axis carries the cross-axis semantic for + /// : for / + /// the parent's AxisTransform has already swapped cross onto screen X; for + /// / cross stays on screen Y. + /// The pinned value is the crossing dummy's parent-scope cross-axis position relative to its own + /// container node's cross-axis position, not the parent's raw coordinate - the child level's own + /// local graph has its own coordinate origin (only reconciled with the parent's absolute frame later, + /// at decomposition time), so only the offset from the container's own placement transfers meaningfully + /// into the child's local frame. This container-relative offset, fed back in as this (not-yet-transformed) + /// level's abstract cross coordinate, is self-consistent because every level in a region shares the same + /// : this level's own upcoming AxisTransform maps it through the + /// identical axis rule the parent's already went through. Gated entirely behind the hierarchy-crossing + /// dummy tag (only crossings are pinned), so the flat + /// (non-hierarchical) pipeline - which never populates - is + /// unaffected. + /// + /// The level's assembled graph, mutated in place to seed the pin on its incoming crossing dummies. + /// The lookup built by . + /// Every level's assembled graph, used to read a parent's already-placed graph. + /// The region's flow direction, deciding which abstract axis carries cross semantics. + /// + /// Thrown when , , or is . + /// + public static void PinIncomingCrossings( + LevelLayeredGraph levelGraph, + IReadOnlyDictionary externalCrossingIndex, + IReadOnlyDictionary levels, + LayoutDirection direction) + { + ArgumentNullException.ThrowIfNull(levelGraph); + ArgumentNullException.ThrowIfNull(externalCrossingIndex); + ArgumentNullException.ThrowIfNull(levels); + + foreach (var crossing in levelGraph.Crossings) + { + // Only an incoming (internal-face) crossing dummy is centered independently of its parent + // scope's already-resolved anchor; an outgoing (external-face) crossing dummy is this same + // level's own concern and needs no pin. + if (crossing.Face != HierarchyCrossingFace.Internal) + { + continue; + } + + if (!externalCrossingIndex.TryGetValue(crossing.Boundary.Port, out var parentInfo)) + { + continue; + } + + // The container node is this same boundary port's owner in the parent level; its own + // cross-axis placement is the reference point the crossing dummy's position is made + // relative to, since the child level's local coordinate origin is not otherwise reconciled + // with the parent's frame until decomposition. + if (!parentInfo.Level.NodeIndex.TryGetValue(crossing.Boundary.Container, out var containerNodeIndex)) + { + continue; + } + + var parentGraph = levels[parentInfo.Level].Graph; + var crossAxis = direction is LayoutDirection.Down or LayoutDirection.Up + ? parentGraph.AugX + : parentGraph.AugY; + + // MergeRegionDecomposer.ChildOffset maps the container's own placed screen position into + // the child level's absolute local origin by adding the container padding on every side + // plus, on the vertical (screen Y) axis only, the container's title-bar height. The + // cross-axis subtraction above only removes the container's own position; it must also + // remove this same padding/title offset so the pinned value lands in the child's own local + // frame, not the parent's. For Right/Left flow the cross axis is screen Y, so both the + // padding and the title-bar height apply; for Down/Up flow the cross axis is screen X, on + // which only the padding applies (the title bar sits on the along axis for those directions). + var containerOffset = direction is LayoutDirection.Down or LayoutDirection.Up + ? HierarchicalLayoutAlgorithm.ContainerPadding + : HierarchicalLayoutAlgorithm.ContainerPadding + HierarchicalLayoutAlgorithm.ResolveContentOffsetHeight(crossing.Boundary.Container); + + var pinned = crossAxis[parentInfo.NodeIndex] - crossAxis[containerNodeIndex] - containerOffset; + + var aug = levelGraph.Graph.AugNodes; + aug[crossing.NodeIndex] = aug[crossing.NodeIndex] with { PinnedCrossAxis = pinned }; + } + } + /// /// Builds the per-level for one : real /// interior nodes translated to s, ordinary interior edges to diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs index 5fc8573..4010ed1 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/MergeRegionDecomposerTests.cs @@ -101,6 +101,101 @@ public void MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonal AssertEveryEdgeAtSharedAnchorIsOrthogonal(tree, expectedReachingAnchor: 4); } + /// + /// Reproduces the internal fan-out showcase (BoundaryPortsShowcaseHorizontal): a single + /// external approach (Sensor) reaches a boundary port that delegates inward to two nested + /// children (Driver and Logger). Before the hierarchy-crossing dummy's cross-axis + /// coordinate was pinned to the parent scope's already-resolved anchor, the shared fan-out dummy + /// centered independently between Driver and Logger, producing an unnecessary + /// extra back-and-forth detour (5 segments / 4 bends) for whichever target sat nearer the anchor. + /// Both delegation connectors must now take a minimal-bend path with no direction reversal. + /// + [Fact] + public void MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBendPathWithNoReversal() + { + // Arrange: the horizontal internal fan-out showcase (one approach, two delegations). + var graph = new LayoutGraph(); + + var sensor = graph.AddNode("sensor", 120, 50); + sensor.Label = "Sensor"; + + var controller = graph.AddNode("controller", 10, 10); + controller.Label = "Controller"; + var command = controller.Ports.AddPort("command"); + command.ExternalLabel = "command"; + command.InternalLabel = "dispatch"; + + var driver = controller.Children.AddNode("driver", 120, 50); + driver.Label = "Driver"; + var logger = controller.Children.AddNode("logger", 120, 50); + logger.Label = "Logger"; + + graph.AddEdge("sensor-command", sensor, command); + var toDriver = controller.Children.AddEdge("command-driver", command, driver); + toDriver.Label = "to-driver"; + var toLogger = controller.Children.AddEdge("command-logger", command, logger); + toLogger.Label = "to-logger"; + + // Act + var tree = new HierarchicalLayoutAlgorithm().Apply(graph, LayoutOptions.ForAlgorithm("layered")); + + // Assert: both delegation connectors take a minimal-bend (at most 3 segments / 2 bends) path + // with no direction reversal along the way - not the old 5-segment/4-bend detour. + var lines = tree.Nodes.OfType().ToList(); + var driverLine = Assert.Single(lines, line => line.MidpointLabel == "to-driver"); + var loggerLine = Assert.Single(lines, line => line.MidpointLabel == "to-logger"); + + AssertMinimalBendPathWithNoReversal(driverLine.Waypoints); + AssertMinimalBendPathWithNoReversal(loggerLine.Waypoints); + } + + /// + /// Asserts a polyline has at most 2 genuine turns and never reverses direction along either axis + /// (a segment vector never points opposite a preceding non-degenerate segment vector on the same + /// axis), confirming the connector takes the shortest reasonable orthogonal path rather than an + /// unnecessary back-and-forth detour. A collinear interior waypoint (same direction as its + /// predecessor) is not counted as a turn. + /// + /// The connector polyline. + private static void AssertMinimalBendPathWithNoReversal(IReadOnlyList waypoints) + { + // Count only genuine turns (a direction change between consecutive non-degenerate segments) - + // a raw waypoint that continues collinearly (same direction as its predecessor) is not a bend, + // it is exactly the kind of interior waypoint Defect 1 stops from being rounded with a spurious + // arc, and must not be counted as an extra bend here either. + var bends = 0; + Point2D? lastDir = null; + for (var i = 1; i < waypoints.Count; i++) + { + var dx = waypoints[i].X - waypoints[i - 1].X; + var dy = waypoints[i].Y - waypoints[i - 1].Y; + if (Math.Abs(dx) < OrthogonalTolerance && Math.Abs(dy) < OrthogonalTolerance) + { + // Degenerate (coincident) segment: no direction to compare. + continue; + } + + if (lastDir is { } prev) + { + // A reversal is a segment whose dot product with the preceding segment is negative - + // i.e. it doubles back rather than continuing to progress toward the target. + var dot = (prev.X * dx) + (prev.Y * dy); + Assert.True(dot >= -OrthogonalTolerance, "Segment direction reverses a preceding segment."); + + // A cross product near zero means the two segments are parallel (collinear) - no turn. + var cross = (prev.X * dy) - (prev.Y * dx); + if (Math.Abs(cross) > OrthogonalTolerance) + { + bends++; + } + } + + lastDir = new Point2D(dx, dy); + } + + Assert.True(bends <= 2, $"Expected at most 2 bends, found {bends}."); + } + /// /// Asserts the layout emits exactly one boundary anchor, that the expected number of connectors /// touch it, and that every such connector is strictly orthogonal along its entire polyline. From 3069b22f84a3efef4a49b64b95309d0eba0dd49f Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 13:19:12 -0400 Subject: [PATCH 18/23] fix(layout): stop insertion-order tie-break separating symmetric fan-in LayeredCorridorRouter.CreateDependency's crossing-count tie-break forced two segments with an equal, non-zero crossing count into a strict left/right routing-slot order purely by insertion order, even when both segments converge on essentially the same target Y (a symmetric fan-in, e.g. two external approaches converging on one shared boundary anchor). This produced asymmetric clearance around the shared point driven by insertion order rather than any genuine geometric preference. Skip creating a dependency for a pair whose target Y values coincide within StraightTolerance, so the topological slot numbering does not arbitrarily separate them. Add a regression test with two mirror-symmetric approach edges converging on one shared target, asserting identical first-bend offsets; verified the test fails (asymmetric offsets) against the prior tie-break and passes with the fix. --- .../Engine/Layered/LayeredCorridorRouter.cs | 7 ++++- .../Engine/Layered/OrthogonalRouterTests.cs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs index bb194f9..b1d425d 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs @@ -152,9 +152,14 @@ private static void CreateDependency(Segment s1, Segment s2) // s2 prefers to be left of s1. _ = new SegDep(s2, s1, c1 - c2); } - else if (c1 > 0) + else if (c1 > 0 && Math.Abs(s1.TargetY - s2.TargetY) > StraightTolerance) { // Equal non-zero crossings: unavoidable conflict — pick s1 left (deterministic tie-break). + // Exception: when both segments converge on essentially the same target point (a symmetric + // fan-in, e.g. two approaches converging on one shared anchor), forcing a strict left/right + // order here produces asymmetric clearance driven purely by insertion order rather than any + // genuine geometric preference. Skip creating a dependency for that pair so the topological + // numbering does not arbitrarily separate them. _ = new SegDep(s1, s2, 0); } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs index cf99bb9..74a0c50 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs @@ -49,6 +49,36 @@ public void OrthogonalRouter_Apply_EveryBendListIsEmptyOrVerticalSegment() $"Unexpected bend geometry with {bend.Count} points.")); } + /// + /// Two mirror-symmetric approach edges converging on one shared target (the boundary-port + /// anchor scenario: Monitor and Operator both approaching the same port from + /// opposite sides) must receive the same routing slot, so their first-bend offsets from the + /// shared target are identical. Both sub-edges share the exact same TargetY (the same + /// target node/port), so 's crossing-count tie-break used to + /// force them into different slots purely by insertion order, producing an asymmetric jog for + /// whichever edge happened to be considered second. With the fix, converging segments whose + /// target Y values coincide are not forced apart, so both bends land at the same corridor X. + /// + [Fact] + public void OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdenticalFirstBendOffsets() + { + // Arrange / Act: two layer-0 sources (one above, one below the shared target's Y) both + // converging on the single layer-1 target node - the exact "Monitor"/"Operator" shape. The + // target has zero height (mirroring the real hierarchy-crossing dummy anchor both approaches + // actually converge on), so both incoming ports collapse onto the same target Y rather than + // being spread across a real node's face by the port distributor. + var graph = BuildRoutedGraph( + [new(60, 40), new(60, 40), new(0, 0)], + [new(0, 2), new(1, 2)]); + + // Assert: both sub-edges bend (neither is a degenerate straight run - the two sources sit on + // opposite sides of the shared target by construction), and the bend X (routing slot) - the + // first-bend offset from the shared anchor - is identical for both. + Assert.Equal(2, graph.AugBendPoints[0].Count); + Assert.Equal(2, graph.AugBendPoints[1].Count); + Assert.Equal(graph.AugBendPoints[0][0].X, graph.AugBendPoints[1][0].X, precision: 9); + } + /// Runs the stages up to and including orthogonal routing and returns the graph. /// Input nodes. /// Input edges. From 3620bdc79cdffbfec5d4c6c5539100344b262836 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 13:19:17 -0400 Subject: [PATCH 19/23] docs(layout): document pinned-anchor and symmetric-convergence fixes Document the Internal-face crossing dummy's PinnedCrossAxis anchoring and LayeredCorridorRouter's shared-target-convergence tie-break exception in the layered-pipeline unit design doc, and add the two new regression tests to the BoundaryPortResolution requirement's reqstream test list and verification doc. --- .../engine/layered-pipeline.md | 21 +++++++++++++++++++ .../engine/layered-pipeline.yaml | 2 ++ .../engine/layered-pipeline.md | 13 +++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index 13e6a24..48404f1 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -165,6 +165,27 @@ hand-built diagonal, which is what keeps external fan-in and multi-level delegat direction→face mapping the decomposer's anchor placement shares); its former reconciliation code and the `OrderCrossings` ordering it once performed are superseded by the recursive crossing minimizer above. +An `Internal`-face crossing dummy — the shared fan-out point a boundary port's delegation edges route +through inside the child level's own layered graph — would otherwise be centered by `BrandesKopfPlacer` +purely among that level's own interior fan-out targets, ignoring the parent scope's already-resolved +anchor position for the same port. `MergeRegionGraphAssembler.PinIncomingCrossings` corrects this: once +a boundary port's `External`-face crossing has been placed in the parent level (guaranteed by +`RunRecursive`'s parent-before-child level iteration order), it computes that placement's cross-axis +offset relative to the port's own container node and seeds it onto the matching `Internal`-face +crossing dummy's `AugNode.PinnedCrossAxis`. `BrandesKopfPlacer.AssignCoordinatesAug` then overrides +just that node's cross-axis coordinate with the pinned value after its ordinary alignment/compaction +pass, so the child's fan-out anchors to the parent's resolved position instead of re-centering +independently. `PinnedCrossAxis` defaults to `null` for every node the flat (non-hierarchical) pipeline +produces, so this override is a no-op there. + +`LayeredCorridorRouter.CreateDependency`'s crossing-count tie-break also recognizes when two segments +converge on the same target Y (within `StraightTolerance`) — the symmetric fan-in case, such as two +external approaches converging on one shared boundary anchor. Previously the deterministic tie-break +forced such a pair into a strict left/right slot order purely by insertion order, producing asymmetric +clearance around the shared point even though neither segment has a genuine geometric preference. The +tie-break now skips creating a dependency for a pair whose target Y values coincide, so the topological +slot numbering does not arbitrarily separate them. + #### Layered Pipeline Dependencies All pipeline types are internal and consume only the geometric value types of the Layout system diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index b3f77d9..677c6f7 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -130,6 +130,8 @@ sections: - FaceForDirection_Up_ReturnsBottomFace - MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal + - MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBendPathWithNoReversal + - OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdenticalFirstBendOffsets - id: Rendering-Layout-LayeredPipeline-Directions title: >- diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 640d285..9d5134e 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -88,6 +88,15 @@ failure. and `MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal` confirm the decomposer projects the combined-pass placement back so every converging edge is routed orthogonally onto the shared anchor with no direct diagonal. + `MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBendPathWithNoReversal` confirms an + internal fan-out's shared crossing dummy now anchors to the parent scope's already-resolved position + (`MergeRegionGraphAssembler.PinIncomingCrossings` / `AugNode.PinnedCrossAxis`) instead of independently + re-centering, so both delegation connectors take a minimal-bend path with no direction reversal rather + than the old back-and-forth detour. + `OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdenticalFirstBendOffsets` confirms + `LayeredCorridorRouter.CreateDependency`'s crossing-count tie-break no longer forces two segments that + converge on the same target Y into different routing slots purely by insertion order, so a symmetric + fan-in receives identical first-bend offsets on both sides. - **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, @@ -205,7 +214,9 @@ failure. FaceForDirection_Right_ReturnsLeftFace, FaceForDirection_Left_ReturnsRightFace, FaceForDirection_Down_ReturnsTopFace, FaceForDirection_Up_ReturnsBottomFace, MergeRegionDecomposer_FanIn_EveryConvergingEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal + MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal, + MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBendPathWithNoReversal, + OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdenticalFirstBendOffsets - **`Rendering-Layout-LayeredPipeline-Directions`**: AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged, AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces, From d99f65e663a909e98bd713cb9b87199e7cdefc64 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 13:19:23 -0400 Subject: [PATCH 20/23] chore(gallery): regenerate gallery SVGs for boundary-port routing fixes Regenerated via gallery.ps1 after the collinear-waypoint, pinned-anchor, and symmetric-fan-in fixes. The three boundary-port showcase SVGs (deep-chain, horizontal, vertical) show the corrected geometry; every other gallery SVG is byte-identical in content (only line-ending metadata changed). --- docs/gallery/boundary-ports-showcase-deep-chain.svg | 4 ++-- docs/gallery/boundary-ports-showcase-horizontal.svg | 6 +++--- docs/gallery/boundary-ports-showcase-vertical.svg | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.svg b/docs/gallery/boundary-ports-showcase-deep-chain.svg index be05ef5..a2141b2 100644 --- a/docs/gallery/boundary-ports-showcase-deep-chain.svg +++ b/docs/gallery/boundary-ports-showcase-deep-chain.svg @@ -39,8 +39,8 @@ Core - - + + handle relay diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg index 314e8c1..e18a4c9 100644 --- a/docs/gallery/boundary-ports-showcase-horizontal.svg +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -38,9 +38,9 @@ Driver Logger - - - + + + dispatch command diff --git a/docs/gallery/boundary-ports-showcase-vertical.svg b/docs/gallery/boundary-ports-showcase-vertical.svg index a0debf2..a5cd684 100644 --- a/docs/gallery/boundary-ports-showcase-vertical.svg +++ b/docs/gallery/boundary-ports-showcase-vertical.svg @@ -38,9 +38,9 @@ Controller Driver - - - + + + dispatch command From 99eb0a88d6b6db22157853c6e69f233e17a57df1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 15:25:59 -0400 Subject: [PATCH 21/23] fix(layout): stop insertion-order tie-break separating symmetric fan-out LayeredCorridorRouter.CreateDependency's crossing-count tie-break guard already skipped the arbitrary left/right tie-break when two segments shared a coincident TargetY (symmetric fan-in, fixed in 3069b22), but had no mirror check for a shared SourceY (symmetric fan-out). dispatch->Driver and dispatch->Logger in the horizontal boundary-ports showcase share the same SourceY (the dispatch anchor) with differing TargetY, so the tie-break still fired and forced an arbitrary insertion-order fork split (16px asymmetry). Extend the guard's condition to also require the source Y values to differ by more than StraightTolerance, so a pair sharing a coincident anchor at either end - target (fan-in) or source (fan-out) - is left without a forced separation. Add OrthogonalRouter_Apply_MirrorSymmetricDivergingEdges_ProduceIdenticalFirstBendOffsets, mirroring the existing fan-in regression test, asserting identical first-bend offsets for a shared-source fan-out. Regenerate gallery SVGs (only boundary-ports-showcase-horizontal.svg changed; vertical and deep-chain galleries are unaffected). Document the mirror fix in the layered-pipeline design/verification docs and reqstream test list. --- .../engine/layered-pipeline.md | 9 ++++++ .../boundary-ports-showcase-horizontal.svg | 2 +- .../engine/layered-pipeline.yaml | 1 + .../engine/layered-pipeline.md | 4 +++ .../Engine/Layered/LayeredCorridorRouter.cs | 10 +++++-- .../Engine/Layered/OrthogonalRouterTests.cs | 28 +++++++++++++++++++ 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/design/rendering-layout/engine/layered-pipeline.md b/docs/design/rendering-layout/engine/layered-pipeline.md index 48404f1..2964ef5 100644 --- a/docs/design/rendering-layout/engine/layered-pipeline.md +++ b/docs/design/rendering-layout/engine/layered-pipeline.md @@ -186,6 +186,15 @@ clearance around the shared point even though neither segment has a genuine geom tie-break now skips creating a dependency for a pair whose target Y values coincide, so the topological slot numbering does not arbitrarily separate them. +The same tie-break also recognizes the mirror-image symmetric fan-out case: two segments diverging from +the same source Y (within `StraightTolerance`), such as one shared boundary anchor branching to two +approaches. Without a matching `SourceY` guard, the tie-break would still force such a pair into a +strict left/right slot order purely by insertion order, producing an asymmetric fork even though neither +segment has a genuine geometric preference. The guard's condition now requires both the target Y values +*and* the source Y values to differ by more than `StraightTolerance` before it creates the deterministic +tie-break dependency, so a pair sharing a coincident anchor at either end — target (fan-in) or source +(fan-out) — is left without a forced separation. + #### Layered Pipeline Dependencies All pipeline types are internal and consume only the geometric value types of the Layout system diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg index e18a4c9..02cbf91 100644 --- a/docs/gallery/boundary-ports-showcase-horizontal.svg +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -39,7 +39,7 @@ Logger - + dispatch diff --git a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml index 677c6f7..8788ec1 100644 --- a/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml +++ b/docs/reqstream/rendering-layout/engine/layered-pipeline.yaml @@ -132,6 +132,7 @@ sections: - MergeRegionDecomposer_FanOut_EveryDelegatedEdge_IsStrictlyOrthogonalWithNoDirectDiagonal - MergeRegionDecomposer_InternalFanOut_DelegationEdges_TakeMinimalBendPathWithNoReversal - OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdenticalFirstBendOffsets + - OrthogonalRouter_Apply_MirrorSymmetricDivergingEdges_ProduceIdenticalFirstBendOffsets - id: Rendering-Layout-LayeredPipeline-Directions title: >- diff --git a/docs/verification/rendering-layout/engine/layered-pipeline.md b/docs/verification/rendering-layout/engine/layered-pipeline.md index 9d5134e..7b204e0 100644 --- a/docs/verification/rendering-layout/engine/layered-pipeline.md +++ b/docs/verification/rendering-layout/engine/layered-pipeline.md @@ -97,6 +97,10 @@ failure. `LayeredCorridorRouter.CreateDependency`'s crossing-count tie-break no longer forces two segments that converge on the same target Y into different routing slots purely by insertion order, so a symmetric fan-in receives identical first-bend offsets on both sides. + `OrthogonalRouter_Apply_MirrorSymmetricDivergingEdges_ProduceIdenticalFirstBendOffsets` confirms the + mirror-image fix: the same tie-break no longer forces two segments that diverge from the same source Y + into different routing slots purely by insertion order, so a symmetric fan-out receives identical + first-bend offsets on both sides. - **Directions** (`Rendering-Layout-LayeredPipeline-Directions`): `AxisTransform_Apply_RightDirection_LeavesCoordinatesUnchanged`, `AxisTransform_Apply_Right_PlacesTargetEastWithCorrectFaces`, diff --git a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs index b1d425d..59fcc6e 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/Layered/LayeredCorridorRouter.cs @@ -152,11 +152,15 @@ private static void CreateDependency(Segment s1, Segment s2) // s2 prefers to be left of s1. _ = new SegDep(s2, s1, c1 - c2); } - else if (c1 > 0 && Math.Abs(s1.TargetY - s2.TargetY) > StraightTolerance) + else if (c1 > 0 + && Math.Abs(s1.TargetY - s2.TargetY) > StraightTolerance + && Math.Abs(s1.SourceY - s2.SourceY) > StraightTolerance) { // Equal non-zero crossings: unavoidable conflict — pick s1 left (deterministic tie-break). - // Exception: when both segments converge on essentially the same target point (a symmetric - // fan-in, e.g. two approaches converging on one shared anchor), forcing a strict left/right + // Exception: when both segments share a coincident anchor at either end — converging on + // essentially the same target point (a symmetric fan-in, e.g. two approaches converging on + // one shared anchor) or diverging from essentially the same source point (a symmetric + // fan-out, e.g. one shared anchor branching to two approaches) — forcing a strict left/right // order here produces asymmetric clearance driven purely by insertion order rather than any // genuine geometric preference. Skip creating a dependency for that pair so the topological // numbering does not arbitrarily separate them. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs index 74a0c50..e8328c9 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/Layered/OrthogonalRouterTests.cs @@ -79,6 +79,34 @@ public void OrthogonalRouter_Apply_MirrorSymmetricConvergingEdges_ProduceIdentic Assert.Equal(graph.AugBendPoints[0][0].X, graph.AugBendPoints[1][0].X, precision: 9); } + /// + /// Two mirror-symmetric diverging edges fanning out from one shared source (the boundary-port + /// anchor scenario: dispatch branching to both Driver and Logger) must + /// receive the same routing slot, so their first-bend offsets from the shared source are + /// identical. Both sub-edges share the exact same SourceY (the same source node/port), + /// so 's crossing-count tie-break used to force them into + /// different slots purely by insertion order, producing an asymmetric fork for whichever edge + /// happened to be considered second. With the fix, diverging segments whose source Y values + /// coincide are not forced apart, so both bends land at the same corridor X. + /// + [Fact] + public void OrthogonalRouter_Apply_MirrorSymmetricDivergingEdges_ProduceIdenticalFirstBendOffsets() + { + // Arrange / Act: a single zero-height layer-0 source (mirroring the real boundary-port anchor + // both branches actually diverge from) fanning out to two equal-size layer-1 targets - the + // exact "dispatch"/"Driver"/"Logger" shape - one above, one below the shared source's Y. + var graph = BuildRoutedGraph( + [new(0, 0), new(60, 40), new(60, 40)], + [new(0, 1), new(0, 2)]); + + // Assert: both sub-edges bend (neither is a degenerate straight run - the two targets sit on + // opposite sides of the shared source by construction), and the bend X (routing slot) - the + // first-bend offset from the shared anchor - is identical for both. + Assert.Equal(2, graph.AugBendPoints[0].Count); + Assert.Equal(2, graph.AugBendPoints[1].Count); + Assert.Equal(graph.AugBendPoints[0][0].X, graph.AugBendPoints[1][0].X, precision: 9); + } + /// Runs the stages up to and including orthogonal routing and returns the graph. /// Input nodes. /// Input edges. From 670fe38259fb84516d9d84405fce7a8dd8e39d94 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 16:11:06 -0400 Subject: [PATCH 22/23] Add PNG companions for boundary-ports, ports, and parallel-edges showcases Add PNG raster companions for 8 gallery diagrams (via GalleryWriter.Png), following the existing layered-pipeline/hierarchical-nested PNG pattern: - boundary-ports-showcase-horizontal/vertical/deep-chain (required) - ports-showcase-horizontal/vertical - parallel-edges-merged/preserved/preserved-vertical Changes: - GalleryCatalog.cs: add 8 PNG filename consts and GalleryImage entries in the 'Raster output' section - GalleryShowcaseTests.cs: add 8 dedicated Gallery_{Diagram}_RendersPng facts, mirroring the existing SVG facts' factories and Themes.Dark - docs/gallery/README.md and 8 new docs/gallery/*.png: regenerated via gallery.ps1 Purely additive: no rendering logic changes. --- docs/gallery/README.md | 32 ++++++ .../boundary-ports-showcase-deep-chain.png | Bin 0 -> 8464 bytes .../boundary-ports-showcase-horizontal.png | Bin 0 -> 6918 bytes .../boundary-ports-showcase-vertical.png | Bin 0 -> 7170 bytes docs/gallery/parallel-edges-merged.png | Bin 0 -> 2241 bytes .../parallel-edges-preserved-vertical.png | Bin 0 -> 4330 bytes docs/gallery/parallel-edges-preserved.png | Bin 0 -> 3826 bytes docs/gallery/ports-showcase-horizontal.png | Bin 0 -> 4708 bytes docs/gallery/ports-showcase-vertical.png | Bin 0 -> 4182 bytes .../GalleryCatalog.cs | 40 +++++++ .../GalleryShowcaseTests.cs | 104 ++++++++++++++++++ 11 files changed, 176 insertions(+) create mode 100644 docs/gallery/boundary-ports-showcase-deep-chain.png create mode 100644 docs/gallery/boundary-ports-showcase-horizontal.png create mode 100644 docs/gallery/boundary-ports-showcase-vertical.png create mode 100644 docs/gallery/parallel-edges-merged.png create mode 100644 docs/gallery/parallel-edges-preserved-vertical.png create mode 100644 docs/gallery/parallel-edges-preserved.png create mode 100644 docs/gallery/ports-showcase-horizontal.png create mode 100644 docs/gallery/ports-showcase-vertical.png diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 536b73f..5fe53e4 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -81,6 +81,38 @@ The layered pipeline rendered to a raster PNG image. The hierarchical nested diagram rendered to a raster PNG image. +![A hub node with a named port on each of its left and right sides as PNG](ports-showcase-horizontal.png) + +The horizontal ports showcase rendered to a raster PNG image. + +![A hub node with a named port on each of its top and bottom sides as PNG](ports-showcase-vertical.png) + +The vertical ports showcase rendered to a raster PNG image. + +![The same three parallel connectors collapsed to a single line as PNG](parallel-edges-merged.png) + +The parallel edges merged diagram rendered to a raster PNG image. + +![Three parallel connectors between the same two boxes, independently routed as PNG](parallel-edges-preserved.png) + +The parallel edges preserved diagram rendered to a raster PNG image. + +![The same three parallel connectors on a downward-flowing pair of boxes as PNG](parallel-edges-preserved-vertical.png) + +The parallel edges preserved vertical diagram rendered to a raster PNG image. + +![A container's boundary port delegating to two children as PNG](boundary-ports-showcase-horizontal.png) + +The horizontal boundary-ports showcase rendered to a raster PNG image. + +![A container's top-face boundary port and one child as PNG](boundary-ports-showcase-vertical.png) + +The vertical boundary-ports showcase rendered to a raster PNG image. + +![A boundary port delegating through a nested boundary port to a leaf as PNG](boundary-ports-showcase-deep-chain.png) + +The boundary-ports deep-chain showcase rendered to a raster PNG image. + ## Box appearance A node's Shape, Keyword, and Compartments properties select the box outline, an italicized keyword line, and labelled diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.png b/docs/gallery/boundary-ports-showcase-deep-chain.png new file mode 100644 index 0000000000000000000000000000000000000000..57ebb9d7d4850ba495a9e33dfe33bb1d4fe985e9 GIT binary patch literal 8464 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 Vx%7;2MOX#`bxreX(PgW<{|828eQ^K) literal 0 HcmV?d00001 diff --git a/docs/gallery/boundary-ports-showcase-horizontal.png b/docs/gallery/boundary-ports-showcase-horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..4b7317cff5953d47d5a274940180d8fc56ff9412 GIT binary patch literal 6918 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 ID}OKGALTW@bpQYW literal 0 HcmV?d00001 diff --git a/docs/gallery/boundary-ports-showcase-vertical.png b/docs/gallery/boundary-ports-showcase-vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..78821a5c1fe4fba19dd91a2d148f1834cce601c4 GIT binary patch literal 7170 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 U;dN<{9 literal 0 HcmV?d00001 diff --git a/docs/gallery/parallel-edges-preserved-vertical.png b/docs/gallery/parallel-edges-preserved-vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..db5fea4eeb7a0aadd2e8c4bb0842e94b541a1ad8 GIT binary patch literal 4330 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/parallel-edges-preserved.png b/docs/gallery/parallel-edges-preserved.png new file mode 100644 index 0000000000000000000000000000000000000000..e49ec261d7418110bb2834c92ccc68d69cc9549b GIT binary patch literal 3826 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/ports-showcase-vertical.png b/docs/gallery/ports-showcase-vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..c87334a36813a852fb04a852a1f4df4b4c38f677 GIT binary patch literal 4182 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 literal 0 HcmV?d00001 diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index aa12b49..a0f9447 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -54,6 +54,14 @@ internal static class GalleryCatalog public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; public const string BoundaryPortsShowcaseDeepChainSvg = "boundary-ports-showcase-deep-chain.svg"; + public const string PortsShowcaseHorizontalPng = "ports-showcase-horizontal.png"; + public const string PortsShowcaseVerticalPng = "ports-showcase-vertical.png"; + public const string ParallelEdgesMergedPng = "parallel-edges-merged.png"; + public const string ParallelEdgesPreservedPng = "parallel-edges-preserved.png"; + public const string ParallelEdgesPreservedVerticalPng = "parallel-edges-preserved-vertical.png"; + public const string BoundaryPortsShowcaseHorizontalPng = "boundary-ports-showcase-horizontal.png"; + public const string BoundaryPortsShowcaseVerticalPng = "boundary-ports-showcase-vertical.png"; + public const string BoundaryPortsShowcaseDeepChainPng = "boundary-ports-showcase-deep-chain.png"; /// Gets the browsable sections of the gallery, in display order. public static IReadOnlyList Sections { get; } = @@ -142,6 +150,38 @@ internal static class GalleryCatalog HierarchicalNestedPng, "Hierarchical nested diagram as PNG", "The hierarchical nested diagram rendered to a raster PNG image."), + new GalleryImage( + PortsShowcaseHorizontalPng, + "A hub node with a named port on each of its left and right sides as PNG", + "The horizontal ports showcase rendered to a raster PNG image."), + new GalleryImage( + PortsShowcaseVerticalPng, + "A hub node with a named port on each of its top and bottom sides as PNG", + "The vertical ports showcase rendered to a raster PNG image."), + new GalleryImage( + ParallelEdgesMergedPng, + "The same three parallel connectors collapsed to a single line as PNG", + "The parallel edges merged diagram rendered to a raster PNG image."), + new GalleryImage( + ParallelEdgesPreservedPng, + "Three parallel connectors between the same two boxes, independently routed as PNG", + "The parallel edges preserved diagram rendered to a raster PNG image."), + new GalleryImage( + ParallelEdgesPreservedVerticalPng, + "The same three parallel connectors on a downward-flowing pair of boxes as PNG", + "The parallel edges preserved vertical diagram rendered to a raster PNG image."), + new GalleryImage( + BoundaryPortsShowcaseHorizontalPng, + "A container's boundary port delegating to two children as PNG", + "The horizontal boundary-ports showcase rendered to a raster PNG image."), + new GalleryImage( + BoundaryPortsShowcaseVerticalPng, + "A container's top-face boundary port and one child as PNG", + "The vertical boundary-ports showcase rendered to a raster PNG image."), + new GalleryImage( + BoundaryPortsShowcaseDeepChainPng, + "A boundary port delegating through a nested boundary port to a leaf as PNG", + "The boundary-ports deep-chain showcase rendered to a raster PNG image."), ]), new GallerySection( "Box appearance", diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index e36dbc8..8a2cf19 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -350,4 +350,108 @@ public void Gallery_BoundaryPortsShowcaseDeepChain_RendersSvg() GalleryDiagrams.BoundaryPortsShowcaseDeepChain(), Themes.Dark); } + + /// + /// Renders the horizontal ports showcase to PNG, proving the raster path handles named + /// left/right ports. + /// + [Fact] + public void Gallery_PortsShowcaseHorizontal_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.PortsShowcaseHorizontalPng, + GalleryDiagrams.PortsShowcaseHorizontal(), + Themes.Dark); + } + + /// + /// Renders the vertical ports showcase to PNG, proving the raster path handles named + /// top/bottom ports. + /// + [Fact] + public void Gallery_PortsShowcaseVertical_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.PortsShowcaseVerticalPng, + GalleryDiagrams.PortsShowcaseVertical(), + Themes.Dark); + } + + /// + /// Renders the parallel-edges-merged diagram to PNG, proving the raster path handles merged + /// parallel connectors. + /// + [Fact] + public void Gallery_ParallelEdgesMerged_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ParallelEdgesMergedPng, + GalleryDiagrams.ParallelEdgesMerged(), + Themes.Dark); + } + + /// + /// Renders the parallel-edges-preserved diagram to PNG, proving the raster path handles + /// independently-routed parallel connectors. + /// + [Fact] + public void Gallery_ParallelEdgesPreserved_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ParallelEdgesPreservedPng, + GalleryDiagrams.ParallelEdgesPreserved(), + Themes.Dark); + } + + /// + /// Renders the vertical-flow parallel-edges-preserved diagram to PNG, proving the raster path + /// handles the top/bottom-anchored companion case. + /// + [Fact] + public void Gallery_ParallelEdgesPreservedVertical_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.ParallelEdgesPreservedVerticalPng, + GalleryDiagrams.ParallelEdgesPreservedVertical(), + Themes.Dark); + } + + /// + /// Renders the horizontal boundary-ports showcase to PNG, proving the raster path handles a + /// container's shared boundary (delegation) port anchor. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseHorizontal_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.BoundaryPortsShowcaseHorizontalPng, + GalleryDiagrams.BoundaryPortsShowcaseHorizontal(), + Themes.Dark); + } + + /// + /// Renders the vertical boundary-ports showcase to PNG, proving the raster path handles the + /// downward-flowing companion case's top-face boundary port. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseVertical_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.BoundaryPortsShowcaseVerticalPng, + GalleryDiagrams.BoundaryPortsShowcaseVertical(), + Themes.Dark); + } + + /// + /// Renders the three-level boundary-ports delegation chain to PNG, proving the raster path + /// handles a chain of two boundary crossings routed in one combined recursive pass. + /// + [Fact] + public void Gallery_BoundaryPortsShowcaseDeepChain_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.BoundaryPortsShowcaseDeepChainPng, + GalleryDiagrams.BoundaryPortsShowcaseDeepChain(), + Themes.Dark); + } } From cf8a15c7817e2a87216494cc20bb95b5a7d20997 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 9 Jul 2026 16:47:05 -0400 Subject: [PATCH 23/23] chore(gallery): keep PNG companions as quiet disk-only siblings Remove the 8 GalleryImage catalog entries for boundary-ports-showcase-horizontal/-vertical/-deep-chain, ports-showcase-horizontal/-vertical, and parallel-edges-merged/-preserved/-preserved-vertical PNGs from the Raster output section. Their [Fact] tests still write the .png files to disk as quiet siblings of the .svg diagrams, but they no longer appear in docs/gallery/README.md. The 8 filename consts are kept since the tests still reference them. ThemeLight/Dark/Print and LayeredPipeline/HierarchicalNested PNG entries are untouched. --- docs/gallery/README.md | 32 ------------------- .../GalleryCatalog.cs | 32 ------------------- 2 files changed, 64 deletions(-) diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 5fe53e4..536b73f 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -81,38 +81,6 @@ The layered pipeline rendered to a raster PNG image. The hierarchical nested diagram rendered to a raster PNG image. -![A hub node with a named port on each of its left and right sides as PNG](ports-showcase-horizontal.png) - -The horizontal ports showcase rendered to a raster PNG image. - -![A hub node with a named port on each of its top and bottom sides as PNG](ports-showcase-vertical.png) - -The vertical ports showcase rendered to a raster PNG image. - -![The same three parallel connectors collapsed to a single line as PNG](parallel-edges-merged.png) - -The parallel edges merged diagram rendered to a raster PNG image. - -![Three parallel connectors between the same two boxes, independently routed as PNG](parallel-edges-preserved.png) - -The parallel edges preserved diagram rendered to a raster PNG image. - -![The same three parallel connectors on a downward-flowing pair of boxes as PNG](parallel-edges-preserved-vertical.png) - -The parallel edges preserved vertical diagram rendered to a raster PNG image. - -![A container's boundary port delegating to two children as PNG](boundary-ports-showcase-horizontal.png) - -The horizontal boundary-ports showcase rendered to a raster PNG image. - -![A container's top-face boundary port and one child as PNG](boundary-ports-showcase-vertical.png) - -The vertical boundary-ports showcase rendered to a raster PNG image. - -![A boundary port delegating through a nested boundary port to a leaf as PNG](boundary-ports-showcase-deep-chain.png) - -The boundary-ports deep-chain showcase rendered to a raster PNG image. - ## Box appearance A node's Shape, Keyword, and Compartments properties select the box outline, an italicized keyword line, and labelled diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index a0f9447..a184897 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -150,38 +150,6 @@ internal static class GalleryCatalog HierarchicalNestedPng, "Hierarchical nested diagram as PNG", "The hierarchical nested diagram rendered to a raster PNG image."), - new GalleryImage( - PortsShowcaseHorizontalPng, - "A hub node with a named port on each of its left and right sides as PNG", - "The horizontal ports showcase rendered to a raster PNG image."), - new GalleryImage( - PortsShowcaseVerticalPng, - "A hub node with a named port on each of its top and bottom sides as PNG", - "The vertical ports showcase rendered to a raster PNG image."), - new GalleryImage( - ParallelEdgesMergedPng, - "The same three parallel connectors collapsed to a single line as PNG", - "The parallel edges merged diagram rendered to a raster PNG image."), - new GalleryImage( - ParallelEdgesPreservedPng, - "Three parallel connectors between the same two boxes, independently routed as PNG", - "The parallel edges preserved diagram rendered to a raster PNG image."), - new GalleryImage( - ParallelEdgesPreservedVerticalPng, - "The same three parallel connectors on a downward-flowing pair of boxes as PNG", - "The parallel edges preserved vertical diagram rendered to a raster PNG image."), - new GalleryImage( - BoundaryPortsShowcaseHorizontalPng, - "A container's boundary port delegating to two children as PNG", - "The horizontal boundary-ports showcase rendered to a raster PNG image."), - new GalleryImage( - BoundaryPortsShowcaseVerticalPng, - "A container's top-face boundary port and one child as PNG", - "The vertical boundary-ports showcase rendered to a raster PNG image."), - new GalleryImage( - BoundaryPortsShowcaseDeepChainPng, - "A boundary port delegating through a nested boundary port to a leaf as PNG", - "The boundary-ports deep-chain showcase rendered to a raster PNG image."), ]), new GallerySection( "Box appearance",